@decantr/verifier 2.4.0 → 2.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -2
- package/dist/index.d.ts +143 -1
- package/dist/index.js +939 -89
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
- package/schema/decantr-ci-report.v1.json +46 -0
- package/schema/scan-report.v1.json +369 -0
package/dist/index.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
2
|
import { createHash } from "crypto";
|
|
3
|
-
import { existsSync as
|
|
3
|
+
import { existsSync as existsSync3, readdirSync as readdirSync3, readFileSync as readFileSync3, realpathSync, statSync as statSync3 } from "fs";
|
|
4
4
|
import { readFile } from "fs/promises";
|
|
5
|
-
import { basename, dirname, extname as
|
|
5
|
+
import { basename, dirname, extname as extname3, isAbsolute as isAbsolute2, join as join3, relative as relative2, resolve } from "path";
|
|
6
6
|
import { evaluateGuard, isV4, validateEssence } from "@decantr/essence-spec";
|
|
7
7
|
import * as ts from "typescript";
|
|
8
8
|
|
|
@@ -754,6 +754,850 @@ function listKnownInteractions() {
|
|
|
754
754
|
return Object.keys(INTERACTION_SIGNALS).sort();
|
|
755
755
|
}
|
|
756
756
|
|
|
757
|
+
// src/scan.ts
|
|
758
|
+
import { existsSync as existsSync2, readdirSync as readdirSync2, readFileSync as readFileSync2, statSync as statSync2 } from "fs";
|
|
759
|
+
import { extname as extname2, isAbsolute, join as join2, relative } from "path";
|
|
760
|
+
var SCAN_REPORT_SCHEMA_URL = "https://decantr.ai/schemas/scan-report.v1.json";
|
|
761
|
+
var SOURCE_EXTENSIONS = /* @__PURE__ */ new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".vue", ".svelte"]);
|
|
762
|
+
var STYLE_EXTENSIONS = /* @__PURE__ */ new Set([".css", ".scss", ".sass", ".less"]);
|
|
763
|
+
var PAGE_EXTENSIONS = /* @__PURE__ */ new Set([".ts", ".tsx", ".js", ".jsx", ".vue", ".svelte", ".html"]);
|
|
764
|
+
var MAX_FILE_READ_BYTES = 512 * 1024;
|
|
765
|
+
var MAX_WALK_FILES = 5e3;
|
|
766
|
+
var MAX_REPORT_ROUTES = 80;
|
|
767
|
+
var SKIP_DIRS = /* @__PURE__ */ new Set([
|
|
768
|
+
".git",
|
|
769
|
+
".next",
|
|
770
|
+
".nuxt",
|
|
771
|
+
".svelte-kit",
|
|
772
|
+
"coverage",
|
|
773
|
+
"dist",
|
|
774
|
+
"build",
|
|
775
|
+
"node_modules",
|
|
776
|
+
"out",
|
|
777
|
+
"target",
|
|
778
|
+
"vendor"
|
|
779
|
+
]);
|
|
780
|
+
var RULE_FILES = [
|
|
781
|
+
"CLAUDE.md",
|
|
782
|
+
"AGENTS.md",
|
|
783
|
+
"GEMINI.md",
|
|
784
|
+
".cursorrules",
|
|
785
|
+
".cursor/rules",
|
|
786
|
+
".claude/rules",
|
|
787
|
+
".github/copilot-instructions.md",
|
|
788
|
+
"copilot-instructions.md",
|
|
789
|
+
".windsurfrules"
|
|
790
|
+
];
|
|
791
|
+
var WEB_FRAMEWORKS = /* @__PURE__ */ new Set([
|
|
792
|
+
"angular",
|
|
793
|
+
"astro",
|
|
794
|
+
"html",
|
|
795
|
+
"nextjs",
|
|
796
|
+
"nuxt",
|
|
797
|
+
"react",
|
|
798
|
+
"solid",
|
|
799
|
+
"svelte",
|
|
800
|
+
"vue"
|
|
801
|
+
]);
|
|
802
|
+
function isRecord(value) {
|
|
803
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
804
|
+
}
|
|
805
|
+
function readTextFile(path, maxBytes = MAX_FILE_READ_BYTES) {
|
|
806
|
+
try {
|
|
807
|
+
const stat = statSync2(path);
|
|
808
|
+
if (!stat.isFile() || stat.size > maxBytes) return null;
|
|
809
|
+
return readFileSync2(path, "utf-8");
|
|
810
|
+
} catch {
|
|
811
|
+
return null;
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
function readPackageJson(projectRoot) {
|
|
815
|
+
const path = join2(projectRoot, "package.json");
|
|
816
|
+
if (!existsSync2(path)) return { value: null, present: false, valid: false };
|
|
817
|
+
const content = readTextFile(path);
|
|
818
|
+
if (!content) return { value: null, present: true, valid: false };
|
|
819
|
+
try {
|
|
820
|
+
const parsed = JSON.parse(content);
|
|
821
|
+
return { value: isRecord(parsed) ? parsed : null, present: true, valid: isRecord(parsed) };
|
|
822
|
+
} catch {
|
|
823
|
+
return { value: null, present: true, valid: false };
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
function dependencyVersion(dependencies, names) {
|
|
827
|
+
for (const name of names) {
|
|
828
|
+
const version = dependencies[name];
|
|
829
|
+
if (version) return version.replace(/^[~^]/, "");
|
|
830
|
+
}
|
|
831
|
+
return null;
|
|
832
|
+
}
|
|
833
|
+
function hasAnyFile(projectRoot, paths) {
|
|
834
|
+
return paths.some((path) => existsSync2(join2(projectRoot, path)));
|
|
835
|
+
}
|
|
836
|
+
function detectPackageManager(projectRoot, pkg) {
|
|
837
|
+
const declared = pkg?.packageManager?.split("@")[0];
|
|
838
|
+
if (declared === "npm" || declared === "pnpm" || declared === "yarn" || declared === "bun") {
|
|
839
|
+
return declared;
|
|
840
|
+
}
|
|
841
|
+
if (existsSync2(join2(projectRoot, "pnpm-lock.yaml"))) return "pnpm";
|
|
842
|
+
if (existsSync2(join2(projectRoot, "yarn.lock"))) return "yarn";
|
|
843
|
+
if (existsSync2(join2(projectRoot, "bun.lockb"))) return "bun";
|
|
844
|
+
if (existsSync2(join2(projectRoot, "package-lock.json"))) return "npm";
|
|
845
|
+
return "unknown";
|
|
846
|
+
}
|
|
847
|
+
function detectPrimaryLanguage(projectRoot, packageJsonPresent) {
|
|
848
|
+
if (packageJsonPresent) return "javascript";
|
|
849
|
+
if (hasAnyFile(projectRoot, ["pyproject.toml", "requirements.txt", "setup.py", "Pipfile"])) return "python";
|
|
850
|
+
if (hasAnyFile(projectRoot, ["go.mod"])) return "go";
|
|
851
|
+
if (hasAnyFile(projectRoot, ["Cargo.toml"])) return "rust";
|
|
852
|
+
if (hasAnyFile(projectRoot, ["index.html", "docs/index.html"])) return "html";
|
|
853
|
+
return "unknown";
|
|
854
|
+
}
|
|
855
|
+
function detectProject(projectRoot) {
|
|
856
|
+
const packageRead = readPackageJson(projectRoot);
|
|
857
|
+
const pkg = packageRead.value;
|
|
858
|
+
const dependencies = { ...pkg?.dependencies ?? {}, ...pkg?.devDependencies ?? {} };
|
|
859
|
+
let framework = "unknown";
|
|
860
|
+
let frameworkVersion = null;
|
|
861
|
+
if (hasAnyFile(projectRoot, ["next.config.js", "next.config.ts", "next.config.mjs"]) || dependencies.next) {
|
|
862
|
+
framework = "nextjs";
|
|
863
|
+
frameworkVersion = dependencyVersion(dependencies, ["next"]);
|
|
864
|
+
} else if (hasAnyFile(projectRoot, ["nuxt.config.js", "nuxt.config.ts"]) || dependencies.nuxt) {
|
|
865
|
+
framework = "nuxt";
|
|
866
|
+
frameworkVersion = dependencyVersion(dependencies, ["nuxt"]);
|
|
867
|
+
} else if (hasAnyFile(projectRoot, ["astro.config.mjs", "astro.config.ts"]) || dependencies.astro) {
|
|
868
|
+
framework = "astro";
|
|
869
|
+
frameworkVersion = dependencyVersion(dependencies, ["astro"]);
|
|
870
|
+
} else if (hasAnyFile(projectRoot, ["svelte.config.js", "svelte.config.ts"]) || dependencies.svelte || dependencies["@sveltejs/kit"]) {
|
|
871
|
+
framework = "svelte";
|
|
872
|
+
frameworkVersion = dependencyVersion(dependencies, ["svelte", "@sveltejs/kit"]);
|
|
873
|
+
} else if (hasAnyFile(projectRoot, ["angular.json"]) || dependencies["@angular/core"]) {
|
|
874
|
+
framework = "angular";
|
|
875
|
+
frameworkVersion = dependencyVersion(dependencies, ["@angular/core"]);
|
|
876
|
+
} else if (dependencies["solid-js"]) {
|
|
877
|
+
framework = "solid";
|
|
878
|
+
frameworkVersion = dependencyVersion(dependencies, ["solid-js"]);
|
|
879
|
+
} else if (dependencies.vue) {
|
|
880
|
+
framework = "vue";
|
|
881
|
+
frameworkVersion = dependencyVersion(dependencies, ["vue"]);
|
|
882
|
+
} else if (dependencies.react) {
|
|
883
|
+
framework = "react";
|
|
884
|
+
frameworkVersion = dependencyVersion(dependencies, ["react"]);
|
|
885
|
+
} else if (hasAnyFile(projectRoot, ["index.html", "docs/index.html"])) {
|
|
886
|
+
framework = "html";
|
|
887
|
+
}
|
|
888
|
+
return {
|
|
889
|
+
framework,
|
|
890
|
+
frameworkVersion,
|
|
891
|
+
packageManager: detectPackageManager(projectRoot, pkg),
|
|
892
|
+
primaryLanguage: detectPrimaryLanguage(projectRoot, packageRead.present),
|
|
893
|
+
hasTypeScript: hasAnyFile(projectRoot, ["tsconfig.json"]),
|
|
894
|
+
hasTailwind: hasAnyFile(projectRoot, [
|
|
895
|
+
"tailwind.config.js",
|
|
896
|
+
"tailwind.config.ts",
|
|
897
|
+
"tailwind.config.mjs",
|
|
898
|
+
"tailwind.config.cjs"
|
|
899
|
+
]),
|
|
900
|
+
hasDecantr: existsSync2(join2(projectRoot, "decantr.essence.json")) || existsSync2(join2(projectRoot, ".decantr")) || Boolean(dependencies["@decantr/cli"] || dependencies["@decantr/css"]),
|
|
901
|
+
packageName: typeof pkg?.name === "string" ? pkg.name : null,
|
|
902
|
+
packageJsonValid: packageRead.valid,
|
|
903
|
+
packageJsonPresent: packageRead.present,
|
|
904
|
+
dependencies
|
|
905
|
+
};
|
|
906
|
+
}
|
|
907
|
+
function shouldSkipDir(name) {
|
|
908
|
+
return SKIP_DIRS.has(name) || name.startsWith(".") && name !== ".github";
|
|
909
|
+
}
|
|
910
|
+
function walkFiles(projectRoot, options = {}) {
|
|
911
|
+
const files = [];
|
|
912
|
+
function walk(dir) {
|
|
913
|
+
if (files.length >= MAX_WALK_FILES) return;
|
|
914
|
+
let entries;
|
|
915
|
+
try {
|
|
916
|
+
entries = readdirSync2(dir);
|
|
917
|
+
} catch {
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
for (const entry of entries) {
|
|
921
|
+
if (files.length >= MAX_WALK_FILES) return;
|
|
922
|
+
const fullPath = join2(dir, entry);
|
|
923
|
+
let stat;
|
|
924
|
+
try {
|
|
925
|
+
stat = statSync2(fullPath);
|
|
926
|
+
} catch {
|
|
927
|
+
continue;
|
|
928
|
+
}
|
|
929
|
+
if (stat.isDirectory()) {
|
|
930
|
+
if (shouldSkipDir(entry) && !options.includeHidden) continue;
|
|
931
|
+
walk(fullPath);
|
|
932
|
+
continue;
|
|
933
|
+
}
|
|
934
|
+
if (!stat.isFile()) continue;
|
|
935
|
+
const rel = relative(projectRoot, fullPath).replace(/\\/g, "/");
|
|
936
|
+
const ext = extname2(entry).toLowerCase();
|
|
937
|
+
if (!options.extensions || options.extensions.has(ext)) {
|
|
938
|
+
files.push(rel);
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
walk(projectRoot);
|
|
943
|
+
return files;
|
|
944
|
+
}
|
|
945
|
+
function segmentToRoute(segment) {
|
|
946
|
+
if (segment.startsWith("(") && segment.endsWith(")")) return null;
|
|
947
|
+
if (segment.startsWith("[") && segment.endsWith("]")) {
|
|
948
|
+
const param = segment.slice(1, -1);
|
|
949
|
+
if (param.startsWith("...")) return `:${param.slice(3)}*`;
|
|
950
|
+
if (param.startsWith("[...") && param.endsWith("]")) return `:${param.slice(4, -1)}*`;
|
|
951
|
+
return `:${param}`;
|
|
952
|
+
}
|
|
953
|
+
return segment;
|
|
954
|
+
}
|
|
955
|
+
function walkNextAppRoutes(dir, projectRoot, segments) {
|
|
956
|
+
let entries;
|
|
957
|
+
try {
|
|
958
|
+
entries = readdirSync2(dir);
|
|
959
|
+
} catch {
|
|
960
|
+
return [];
|
|
961
|
+
}
|
|
962
|
+
const routes = [];
|
|
963
|
+
const pageFile = entries.find((entry) => /^page\.(tsx|ts|jsx|js)$/.test(entry));
|
|
964
|
+
const hasLayout = entries.some((entry) => /^layout\.(tsx|ts|jsx|js)$/.test(entry));
|
|
965
|
+
if (pageFile) {
|
|
966
|
+
routes.push({
|
|
967
|
+
path: `/${segments.filter(Boolean).join("/")}` || "/",
|
|
968
|
+
file: relative(projectRoot, join2(dir, pageFile)).replace(/\\/g, "/"),
|
|
969
|
+
hasLayout
|
|
970
|
+
});
|
|
971
|
+
}
|
|
972
|
+
for (const entry of entries) {
|
|
973
|
+
if (shouldSkipDir(entry)) continue;
|
|
974
|
+
const fullPath = join2(dir, entry);
|
|
975
|
+
try {
|
|
976
|
+
if (!statSync2(fullPath).isDirectory()) continue;
|
|
977
|
+
} catch {
|
|
978
|
+
continue;
|
|
979
|
+
}
|
|
980
|
+
const routeSegment = segmentToRoute(entry);
|
|
981
|
+
routes.push(...walkNextAppRoutes(fullPath, projectRoot, routeSegment === null ? segments : [...segments, routeSegment]));
|
|
982
|
+
}
|
|
983
|
+
return routes;
|
|
984
|
+
}
|
|
985
|
+
function fileRouteFromPath(file, baseDir) {
|
|
986
|
+
let withoutExt = file.slice(0, -extname2(file).length);
|
|
987
|
+
if (withoutExt.endsWith("/index")) withoutExt = withoutExt.slice(0, -"/index".length);
|
|
988
|
+
withoutExt = withoutExt.replace(new RegExp(`^${baseDir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`), "");
|
|
989
|
+
const parts = withoutExt.split("/").filter(Boolean).map((part) => segmentToRoute(part)).filter(Boolean);
|
|
990
|
+
return `/${parts.join("/")}` || "/";
|
|
991
|
+
}
|
|
992
|
+
function scanFileRoutes(projectRoot, baseDir, extensions = PAGE_EXTENSIONS) {
|
|
993
|
+
const fullBase = join2(projectRoot, baseDir);
|
|
994
|
+
if (!existsSync2(fullBase)) return [];
|
|
995
|
+
return walkFiles(fullBase, { extensions }).map((file) => {
|
|
996
|
+
const rel = relative(projectRoot, join2(fullBase, file)).replace(/\\/g, "/");
|
|
997
|
+
return { path: fileRouteFromPath(rel, baseDir), file: rel, hasLayout: false };
|
|
998
|
+
});
|
|
999
|
+
}
|
|
1000
|
+
function scanReactRouter(projectRoot) {
|
|
1001
|
+
const files = walkFiles(projectRoot, { extensions: SOURCE_EXTENSIONS });
|
|
1002
|
+
const routes = /* @__PURE__ */ new Map();
|
|
1003
|
+
let hashRouting = false;
|
|
1004
|
+
for (const file of files) {
|
|
1005
|
+
const content = readTextFile(join2(projectRoot, file));
|
|
1006
|
+
if (!content) continue;
|
|
1007
|
+
if (content.includes("HashRouter") || content.includes("createHashRouter")) hashRouting = true;
|
|
1008
|
+
const jsxRouteRegex = /<Route\b[^>]*\bpath\s*=\s*["']([^"']+)["']/g;
|
|
1009
|
+
let match;
|
|
1010
|
+
while ((match = jsxRouteRegex.exec(content)) !== null) {
|
|
1011
|
+
const route = match[1] || "/";
|
|
1012
|
+
routes.set(route, { path: route, file, hasLayout: false });
|
|
1013
|
+
}
|
|
1014
|
+
const objectRouteRegex = /\bpath\s*:\s*["']([^"']+)["']/g;
|
|
1015
|
+
while ((match = objectRouteRegex.exec(content)) !== null) {
|
|
1016
|
+
const route = match[1] || "/";
|
|
1017
|
+
if (route.startsWith("/")) routes.set(route, { path: route, file, hasLayout: false });
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
return { routes: [...routes.values()], hashRouting };
|
|
1021
|
+
}
|
|
1022
|
+
function scanRoutes(projectRoot, detection) {
|
|
1023
|
+
const appRoutes = ["src/app", "app"].flatMap(
|
|
1024
|
+
(dir) => existsSync2(join2(projectRoot, dir)) ? walkNextAppRoutes(join2(projectRoot, dir), projectRoot, []) : []
|
|
1025
|
+
);
|
|
1026
|
+
const pagesRoutes = ["src/pages", "pages"].flatMap((dir) => scanFileRoutes(projectRoot, dir));
|
|
1027
|
+
if (detection.framework === "nextjs") {
|
|
1028
|
+
if (appRoutes.length > 0 && pagesRoutes.length > 0) return { strategy: "mixed-next-router", routes: [...appRoutes, ...pagesRoutes] };
|
|
1029
|
+
if (appRoutes.length > 0) return { strategy: "app-router", routes: appRoutes };
|
|
1030
|
+
if (pagesRoutes.length > 0) return { strategy: "pages-router", routes: pagesRoutes };
|
|
1031
|
+
}
|
|
1032
|
+
if (detection.framework === "svelte") {
|
|
1033
|
+
const routes = scanFileRoutes(projectRoot, "src/routes", /* @__PURE__ */ new Set([".svelte", ".ts", ".js"]));
|
|
1034
|
+
if (routes.length > 0) return { strategy: "sveltekit-router", routes };
|
|
1035
|
+
}
|
|
1036
|
+
if (detection.framework === "nuxt") {
|
|
1037
|
+
const routes = ["pages", "app/pages"].flatMap((dir) => scanFileRoutes(projectRoot, dir, /* @__PURE__ */ new Set([".vue"])));
|
|
1038
|
+
if (routes.length > 0) return { strategy: "nuxt-router", routes };
|
|
1039
|
+
}
|
|
1040
|
+
if (detection.framework === "vue") {
|
|
1041
|
+
const router = scanReactRouter(projectRoot);
|
|
1042
|
+
if (router.routes.length > 0) return { strategy: "vue-router", routes: router.routes };
|
|
1043
|
+
}
|
|
1044
|
+
const reactRouter = scanReactRouter(projectRoot);
|
|
1045
|
+
if (reactRouter.routes.length > 0) return { strategy: "react-router", routes: reactRouter.routes };
|
|
1046
|
+
if (pagesRoutes.length > 0) return { strategy: "pages-router", routes: pagesRoutes };
|
|
1047
|
+
if (existsSync2(join2(projectRoot, "index.html"))) {
|
|
1048
|
+
return { strategy: "static-html", routes: [{ path: "/", file: "index.html", hasLayout: false }] };
|
|
1049
|
+
}
|
|
1050
|
+
if (existsSync2(join2(projectRoot, "docs", "index.html"))) {
|
|
1051
|
+
return { strategy: "static-html", routes: [{ path: "/", file: "docs/index.html", hasLayout: false }] };
|
|
1052
|
+
}
|
|
1053
|
+
return { strategy: "none", routes: [] };
|
|
1054
|
+
}
|
|
1055
|
+
function countComponents(projectRoot, routes) {
|
|
1056
|
+
const sourceFiles = walkFiles(projectRoot, { extensions: SOURCE_EXTENSIONS });
|
|
1057
|
+
const componentFiles = sourceFiles.filter((file) => {
|
|
1058
|
+
const base = file.split("/").pop() ?? file;
|
|
1059
|
+
return /^[A-Z][\w-]*\.(tsx|ts|jsx|js|vue|svelte)$/.test(base);
|
|
1060
|
+
});
|
|
1061
|
+
const directories = [...new Set(componentFiles.map((file) => file.split("/").slice(0, -1).join("/")).filter(Boolean))].slice(0, 16);
|
|
1062
|
+
return {
|
|
1063
|
+
pageCount: routes.routes.length,
|
|
1064
|
+
componentCount: componentFiles.length,
|
|
1065
|
+
directories
|
|
1066
|
+
};
|
|
1067
|
+
}
|
|
1068
|
+
function extractCssEvidence(content) {
|
|
1069
|
+
const variableMatches = [...content.matchAll(/--[\w-]+\s*:/g)];
|
|
1070
|
+
const colorMatches = [...content.matchAll(/#[0-9a-fA-F]{3,8}\b|rgb[a]?\(|hsl[a]?\(/g)];
|
|
1071
|
+
const themeSignals = /* @__PURE__ */ new Set();
|
|
1072
|
+
if (/\bdark\b|color-scheme:\s*dark|\[data-theme=['"]dark['"]|\.dark\b/.test(content)) themeSignals.add("dark mode");
|
|
1073
|
+
if (/\[data-theme=|data-theme|theme-\w+/.test(content)) themeSignals.add("theme selector");
|
|
1074
|
+
if (/prefers-color-scheme/.test(content)) themeSignals.add("system color preference");
|
|
1075
|
+
return {
|
|
1076
|
+
variables: variableMatches.length,
|
|
1077
|
+
colors: colorMatches.length,
|
|
1078
|
+
dark: themeSignals.has("dark mode") || themeSignals.has("system color preference"),
|
|
1079
|
+
themeSignals: [...themeSignals]
|
|
1080
|
+
};
|
|
1081
|
+
}
|
|
1082
|
+
function scanStyling(projectRoot, detection) {
|
|
1083
|
+
const deps = detection.dependencies;
|
|
1084
|
+
const cssFiles = walkFiles(projectRoot, { extensions: STYLE_EXTENSIONS });
|
|
1085
|
+
const themeSignals = /* @__PURE__ */ new Set();
|
|
1086
|
+
let cssVariableCount = 0;
|
|
1087
|
+
let colorTokenCount = 0;
|
|
1088
|
+
let darkMode = false;
|
|
1089
|
+
let configFile = null;
|
|
1090
|
+
let approach = "unknown";
|
|
1091
|
+
const tailwindConfig = ["tailwind.config.js", "tailwind.config.ts", "tailwind.config.mjs", "tailwind.config.cjs"].find(
|
|
1092
|
+
(file) => existsSync2(join2(projectRoot, file))
|
|
1093
|
+
);
|
|
1094
|
+
if (tailwindConfig || deps.tailwindcss) {
|
|
1095
|
+
approach = "tailwind";
|
|
1096
|
+
configFile = tailwindConfig ?? "package.json";
|
|
1097
|
+
} else if (deps["@decantr/css"] || existsSync2(join2(projectRoot, "src/styles/tokens.css")) && existsSync2(join2(projectRoot, "src/styles/treatments.css"))) {
|
|
1098
|
+
approach = "decantr-css";
|
|
1099
|
+
configFile = deps["@decantr/css"] ? "package.json" : "src/styles/tokens.css";
|
|
1100
|
+
} else if (deps.bootstrap) {
|
|
1101
|
+
approach = "bootstrap";
|
|
1102
|
+
configFile = "package.json";
|
|
1103
|
+
} else if (deps["@mui/material"]) {
|
|
1104
|
+
approach = "mui";
|
|
1105
|
+
configFile = "package.json";
|
|
1106
|
+
} else if (deps["@chakra-ui/react"]) {
|
|
1107
|
+
approach = "chakra";
|
|
1108
|
+
configFile = "package.json";
|
|
1109
|
+
} else if (cssFiles.some((file) => file.endsWith(".module.css"))) {
|
|
1110
|
+
approach = "css-modules";
|
|
1111
|
+
} else if (cssFiles.length > 0) {
|
|
1112
|
+
approach = "css";
|
|
1113
|
+
}
|
|
1114
|
+
for (const file of cssFiles.slice(0, 80)) {
|
|
1115
|
+
const content = readTextFile(join2(projectRoot, file), 256 * 1024);
|
|
1116
|
+
if (!content) continue;
|
|
1117
|
+
const evidence = extractCssEvidence(content);
|
|
1118
|
+
cssVariableCount += evidence.variables;
|
|
1119
|
+
colorTokenCount += evidence.colors;
|
|
1120
|
+
darkMode ||= evidence.dark;
|
|
1121
|
+
for (const signal of evidence.themeSignals) themeSignals.add(signal);
|
|
1122
|
+
}
|
|
1123
|
+
return {
|
|
1124
|
+
approach,
|
|
1125
|
+
configFile,
|
|
1126
|
+
cssVariableCount,
|
|
1127
|
+
colorTokenCount,
|
|
1128
|
+
darkMode,
|
|
1129
|
+
themeSignals: [...themeSignals]
|
|
1130
|
+
};
|
|
1131
|
+
}
|
|
1132
|
+
function findAssistantRules(projectRoot) {
|
|
1133
|
+
return RULE_FILES.filter((file) => existsSync2(join2(projectRoot, file)));
|
|
1134
|
+
}
|
|
1135
|
+
function scanStaticHosting(projectRoot, detection) {
|
|
1136
|
+
const packageRead = readPackageJson(projectRoot);
|
|
1137
|
+
const pkg = packageRead.value;
|
|
1138
|
+
const evidence = [];
|
|
1139
|
+
const homepageUrl = typeof pkg?.homepage === "string" ? pkg.homepage : null;
|
|
1140
|
+
let basePath = null;
|
|
1141
|
+
let hashRouting = false;
|
|
1142
|
+
if (homepageUrl?.includes("github.io")) {
|
|
1143
|
+
evidence.push("package.json homepage points at GitHub Pages");
|
|
1144
|
+
try {
|
|
1145
|
+
const url = new URL(homepageUrl);
|
|
1146
|
+
basePath = url.pathname && url.pathname !== "/" ? url.pathname : null;
|
|
1147
|
+
} catch {
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
if (detection.dependencies["gh-pages"] || pkg?.scripts?.deploy?.includes("gh-pages")) {
|
|
1151
|
+
evidence.push("package scripts or dependencies reference gh-pages");
|
|
1152
|
+
}
|
|
1153
|
+
if (existsSync2(join2(projectRoot, "docs", "index.html"))) evidence.push("docs/index.html can serve GitHub Pages");
|
|
1154
|
+
if (existsSync2(join2(projectRoot, "404.html")) || existsSync2(join2(projectRoot, "docs", "404.html"))) {
|
|
1155
|
+
evidence.push("404.html fallback is present");
|
|
1156
|
+
}
|
|
1157
|
+
const workflowDir = join2(projectRoot, ".github", "workflows");
|
|
1158
|
+
if (existsSync2(workflowDir)) {
|
|
1159
|
+
for (const file of walkFiles(workflowDir, { extensions: /* @__PURE__ */ new Set([".yml", ".yaml"]) }).slice(0, 20)) {
|
|
1160
|
+
const content = readTextFile(join2(workflowDir, file));
|
|
1161
|
+
if (content && /pages|gh-pages|upload-pages-artifact|deploy-pages/i.test(content)) {
|
|
1162
|
+
evidence.push(`GitHub Pages workflow hint: .github/workflows/${file}`);
|
|
1163
|
+
break;
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
const sourceFiles = walkFiles(projectRoot, { extensions: SOURCE_EXTENSIONS });
|
|
1168
|
+
for (const file of sourceFiles.slice(0, 200)) {
|
|
1169
|
+
const content = readTextFile(join2(projectRoot, file), 128 * 1024);
|
|
1170
|
+
if (!content) continue;
|
|
1171
|
+
if (content.includes("HashRouter") || content.includes("createHashRouter")) {
|
|
1172
|
+
hashRouting = true;
|
|
1173
|
+
evidence.push(`hash routing detected in ${file}`);
|
|
1174
|
+
break;
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
for (const config of ["vite.config.ts", "vite.config.js", "vite.config.mjs"]) {
|
|
1178
|
+
const content = readTextFile(join2(projectRoot, config), 128 * 1024);
|
|
1179
|
+
const baseMatch = content?.match(/\bbase\s*:\s*['"]([^'"]+)['"]/);
|
|
1180
|
+
if (baseMatch?.[1]) {
|
|
1181
|
+
basePath = baseMatch[1];
|
|
1182
|
+
evidence.push(`Vite base path configured in ${config}`);
|
|
1183
|
+
break;
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
return {
|
|
1187
|
+
githubPagesLikely: evidence.some((item) => item.toLowerCase().includes("github") || item.toLowerCase().includes("pages")),
|
|
1188
|
+
evidence: [...new Set(evidence)].slice(0, 12),
|
|
1189
|
+
homepageUrl,
|
|
1190
|
+
basePath,
|
|
1191
|
+
hashRouting
|
|
1192
|
+
};
|
|
1193
|
+
}
|
|
1194
|
+
function buildApplicability(detection, routes, components) {
|
|
1195
|
+
if (!WEB_FRAMEWORKS.has(detection.framework)) {
|
|
1196
|
+
const label = detection.primaryLanguage === "python" ? "Not a Brownfield UI target" : "No web UI target detected";
|
|
1197
|
+
return {
|
|
1198
|
+
status: "not_applicable",
|
|
1199
|
+
label,
|
|
1200
|
+
reasons: [
|
|
1201
|
+
detection.primaryLanguage === "python" ? "This looks like a Python/backend repository rather than a frontend app." : "Decantr did not find a supported frontend framework or static HTML entrypoint."
|
|
1202
|
+
]
|
|
1203
|
+
};
|
|
1204
|
+
}
|
|
1205
|
+
if (routes.routes.length > 0 || components.componentCount > 0) {
|
|
1206
|
+
return {
|
|
1207
|
+
status: "strong_fit",
|
|
1208
|
+
label: "Good Brownfield scan target",
|
|
1209
|
+
reasons: ["A supported web UI framework or static site entrypoint was detected.", "Routes or UI component files are present."]
|
|
1210
|
+
};
|
|
1211
|
+
}
|
|
1212
|
+
return {
|
|
1213
|
+
status: "partial_fit",
|
|
1214
|
+
label: "Partial Brownfield signal",
|
|
1215
|
+
reasons: ["A web framework was detected, but route/component evidence is thin."]
|
|
1216
|
+
};
|
|
1217
|
+
}
|
|
1218
|
+
function buildConfidence(applicability, detection, routes, styling) {
|
|
1219
|
+
let score = 20;
|
|
1220
|
+
const reasons = [];
|
|
1221
|
+
if (detection.packageJsonPresent && detection.packageJsonValid) {
|
|
1222
|
+
score += 15;
|
|
1223
|
+
reasons.push("package.json was readable");
|
|
1224
|
+
}
|
|
1225
|
+
if (WEB_FRAMEWORKS.has(detection.framework)) {
|
|
1226
|
+
score += 25;
|
|
1227
|
+
reasons.push(`${detection.framework} framework signal found`);
|
|
1228
|
+
}
|
|
1229
|
+
if (routes.routes.length > 0) {
|
|
1230
|
+
score += 20;
|
|
1231
|
+
reasons.push(`${routes.routes.length} route signal(s) found`);
|
|
1232
|
+
}
|
|
1233
|
+
if (styling.approach !== "unknown") {
|
|
1234
|
+
score += 10;
|
|
1235
|
+
reasons.push(`${styling.approach} styling signal found`);
|
|
1236
|
+
}
|
|
1237
|
+
if (applicability.status === "not_applicable") score = Math.min(score, 35);
|
|
1238
|
+
const clamped = Math.max(5, Math.min(98, score));
|
|
1239
|
+
return {
|
|
1240
|
+
score: clamped,
|
|
1241
|
+
level: clamped >= 75 ? "high" : clamped >= 45 ? "medium" : "low",
|
|
1242
|
+
reasons: reasons.length > 0 ? reasons : ["Only weak project signals were found."]
|
|
1243
|
+
};
|
|
1244
|
+
}
|
|
1245
|
+
function buildFindings(input) {
|
|
1246
|
+
const findings = [];
|
|
1247
|
+
const { detection, routes, styling, hosting, assistantRules, applicability, pagesProbe } = input;
|
|
1248
|
+
if (!detection.packageJsonPresent && detection.primaryLanguage !== "html") {
|
|
1249
|
+
findings.push({
|
|
1250
|
+
id: "package-manifest-missing",
|
|
1251
|
+
severity: "warn",
|
|
1252
|
+
title: "No JavaScript package manifest",
|
|
1253
|
+
message: "Decantr could not find package.json, so framework and dependency confidence is limited.",
|
|
1254
|
+
evidence: ["package.json missing"]
|
|
1255
|
+
});
|
|
1256
|
+
} else if (detection.packageJsonPresent && !detection.packageJsonValid) {
|
|
1257
|
+
findings.push({
|
|
1258
|
+
id: "package-manifest-invalid",
|
|
1259
|
+
severity: "warn",
|
|
1260
|
+
title: "package.json could not be parsed",
|
|
1261
|
+
message: "The scan continued with file-system evidence, but dependency confidence is limited.",
|
|
1262
|
+
evidence: ["package.json parse failed"]
|
|
1263
|
+
});
|
|
1264
|
+
}
|
|
1265
|
+
if (applicability.status === "not_applicable") {
|
|
1266
|
+
findings.push({
|
|
1267
|
+
id: "not-brownfield-ui-target",
|
|
1268
|
+
severity: "info",
|
|
1269
|
+
title: "Not a Brownfield UI target",
|
|
1270
|
+
message: "This repository does not look like a supported frontend application for Decantr adoption.",
|
|
1271
|
+
evidence: [`primary language: ${detection.primaryLanguage}`, `framework: ${detection.framework}`],
|
|
1272
|
+
recommendation: "Use Decantr scan on a frontend app or GitHub Pages site."
|
|
1273
|
+
});
|
|
1274
|
+
return findings;
|
|
1275
|
+
}
|
|
1276
|
+
if (routes.routes.length === 0) {
|
|
1277
|
+
findings.push({
|
|
1278
|
+
id: "route-map-thin",
|
|
1279
|
+
severity: "warn",
|
|
1280
|
+
title: "Route map is thin",
|
|
1281
|
+
message: "No route declarations were detected, so a future Decantr contract would need manual route confirmation.",
|
|
1282
|
+
evidence: [`route strategy: ${routes.strategy}`],
|
|
1283
|
+
recommendation: "Run `decantr analyze` locally when you are ready for a proposal-backed attach."
|
|
1284
|
+
});
|
|
1285
|
+
} else {
|
|
1286
|
+
findings.push({
|
|
1287
|
+
id: "route-map-detected",
|
|
1288
|
+
severity: "success",
|
|
1289
|
+
title: "Route map detected",
|
|
1290
|
+
message: `Decantr found ${routes.routes.length} route signal(s) that can seed a Brownfield contract.`,
|
|
1291
|
+
evidence: routes.routes.slice(0, 5).map((route) => `${route.path} -> ${route.file}`)
|
|
1292
|
+
});
|
|
1293
|
+
}
|
|
1294
|
+
if (styling.approach === "unknown") {
|
|
1295
|
+
findings.push({
|
|
1296
|
+
id: "style-authority-unclear",
|
|
1297
|
+
severity: "warn",
|
|
1298
|
+
title: "Style authority is unclear",
|
|
1299
|
+
message: "The scan did not find Tailwind, Decantr CSS, a common component library, or clear CSS files.",
|
|
1300
|
+
evidence: ["styling approach: unknown"]
|
|
1301
|
+
});
|
|
1302
|
+
} else {
|
|
1303
|
+
findings.push({
|
|
1304
|
+
id: "style-authority-detected",
|
|
1305
|
+
severity: "success",
|
|
1306
|
+
title: "Style authority detected",
|
|
1307
|
+
message: `The project appears to use ${styling.approach}. Decantr should preserve that authority during Brownfield adoption.`,
|
|
1308
|
+
evidence: [styling.configFile ? `${styling.approach}: ${styling.configFile}` : `styling approach: ${styling.approach}`]
|
|
1309
|
+
});
|
|
1310
|
+
}
|
|
1311
|
+
if (hosting.githubPagesLikely) {
|
|
1312
|
+
findings.push({
|
|
1313
|
+
id: "github-pages-signal",
|
|
1314
|
+
severity: "info",
|
|
1315
|
+
title: "GitHub Pages signal",
|
|
1316
|
+
message: "The repository has static-hosting evidence. The report separates repo evidence from published-site evidence.",
|
|
1317
|
+
evidence: hosting.evidence.slice(0, 5)
|
|
1318
|
+
});
|
|
1319
|
+
}
|
|
1320
|
+
if (hosting.githubPagesLikely && !hosting.hashRouting && detection.framework === "react") {
|
|
1321
|
+
findings.push({
|
|
1322
|
+
id: "github-pages-routing-risk",
|
|
1323
|
+
severity: "warn",
|
|
1324
|
+
title: "Static routing risk",
|
|
1325
|
+
message: "React apps on GitHub Pages usually need hash routing or a 404 fallback for direct routes.",
|
|
1326
|
+
evidence: ["HashRouter not detected"],
|
|
1327
|
+
recommendation: "Confirm whether the published site uses hash routing or a Pages 404 fallback."
|
|
1328
|
+
});
|
|
1329
|
+
}
|
|
1330
|
+
if (assistantRules.length === 0) {
|
|
1331
|
+
findings.push({
|
|
1332
|
+
id: "assistant-rules-missing",
|
|
1333
|
+
severity: "info",
|
|
1334
|
+
title: "No assistant rules detected",
|
|
1335
|
+
message: "No common AI-assistant rule file was found. Decantr can add scoped task context after adoption.",
|
|
1336
|
+
evidence: ["No CLAUDE.md, AGENTS.md, .cursorrules, or Copilot instructions detected"]
|
|
1337
|
+
});
|
|
1338
|
+
}
|
|
1339
|
+
if (pagesProbe?.checked && !pagesProbe.reachable) {
|
|
1340
|
+
findings.push({
|
|
1341
|
+
id: "published-site-unreachable",
|
|
1342
|
+
severity: "warn",
|
|
1343
|
+
title: "Published site was not reachable",
|
|
1344
|
+
message: "Decantr could inspect the repository, but the inferred published site did not return a usable response.",
|
|
1345
|
+
evidence: [pagesProbe.error ?? `status: ${pagesProbe.status ?? "unknown"}`]
|
|
1346
|
+
});
|
|
1347
|
+
}
|
|
1348
|
+
return findings;
|
|
1349
|
+
}
|
|
1350
|
+
function buildCommands(applicability) {
|
|
1351
|
+
const commands = ["npx @decantr/cli scan"];
|
|
1352
|
+
if (applicability.status === "strong_fit" || applicability.status === "partial_fit") {
|
|
1353
|
+
commands.push("npx @decantr/cli adopt --yes");
|
|
1354
|
+
commands.push("npx @decantr/cli doctor");
|
|
1355
|
+
}
|
|
1356
|
+
return commands;
|
|
1357
|
+
}
|
|
1358
|
+
async function scanProject(projectRoot, options = {}) {
|
|
1359
|
+
const detection = detectProject(projectRoot);
|
|
1360
|
+
const routes = scanRoutes(projectRoot, detection);
|
|
1361
|
+
routes.routes = routes.routes.slice(0, MAX_REPORT_ROUTES);
|
|
1362
|
+
const components = countComponents(projectRoot, routes);
|
|
1363
|
+
const styling = scanStyling(projectRoot, detection);
|
|
1364
|
+
const assistantRules = findAssistantRules(projectRoot);
|
|
1365
|
+
const staticHosting = scanStaticHosting(projectRoot, detection);
|
|
1366
|
+
const pagesProbe = options.pagesProbe ?? null;
|
|
1367
|
+
const applicability = buildApplicability(detection, routes, components);
|
|
1368
|
+
const confidence = buildConfidence(applicability, detection, routes, styling);
|
|
1369
|
+
const rawInput = options.input ?? { kind: "local", value: "." };
|
|
1370
|
+
const input = rawInput.kind === "local" && isAbsolute(rawInput.value) ? { ...rawInput, value: "." } : rawInput;
|
|
1371
|
+
return {
|
|
1372
|
+
$schema: SCAN_REPORT_SCHEMA_URL,
|
|
1373
|
+
schemaVersion: "scan-report.v1",
|
|
1374
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1375
|
+
input,
|
|
1376
|
+
source: {
|
|
1377
|
+
repository: options.repository ?? null,
|
|
1378
|
+
publishedSiteUrl: options.publishedSiteUrl ?? staticHosting.homepageUrl ?? null
|
|
1379
|
+
},
|
|
1380
|
+
confidence,
|
|
1381
|
+
applicability,
|
|
1382
|
+
project: {
|
|
1383
|
+
framework: detection.framework,
|
|
1384
|
+
frameworkVersion: detection.frameworkVersion,
|
|
1385
|
+
packageManager: detection.packageManager,
|
|
1386
|
+
primaryLanguage: detection.primaryLanguage,
|
|
1387
|
+
hasTypeScript: detection.hasTypeScript,
|
|
1388
|
+
hasTailwind: detection.hasTailwind,
|
|
1389
|
+
hasDecantr: detection.hasDecantr,
|
|
1390
|
+
packageName: detection.packageName
|
|
1391
|
+
},
|
|
1392
|
+
routes: {
|
|
1393
|
+
strategy: routes.strategy,
|
|
1394
|
+
count: routes.routes.length,
|
|
1395
|
+
items: routes.routes
|
|
1396
|
+
},
|
|
1397
|
+
components,
|
|
1398
|
+
styling,
|
|
1399
|
+
staticHosting,
|
|
1400
|
+
assistant: {
|
|
1401
|
+
ruleFiles: assistantRules
|
|
1402
|
+
},
|
|
1403
|
+
pagesProbe,
|
|
1404
|
+
findings: buildFindings({ detection, routes, styling, hosting: staticHosting, assistantRules, applicability, pagesProbe }),
|
|
1405
|
+
recommendedCommands: buildCommands(applicability),
|
|
1406
|
+
privacy: {
|
|
1407
|
+
sourceUploaded: input.kind !== "local",
|
|
1408
|
+
persistedByDecantr: false,
|
|
1409
|
+
notes: [
|
|
1410
|
+
"V1 scans are read-only and do not install dependencies, build projects, execute scripts, or open pull requests.",
|
|
1411
|
+
"Hosted scans use temporary public-repo checkouts and return an ephemeral report."
|
|
1412
|
+
]
|
|
1413
|
+
}
|
|
1414
|
+
};
|
|
1415
|
+
}
|
|
1416
|
+
function createUnavailableScanReport(input) {
|
|
1417
|
+
return {
|
|
1418
|
+
$schema: SCAN_REPORT_SCHEMA_URL,
|
|
1419
|
+
schemaVersion: "scan-report.v1",
|
|
1420
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1421
|
+
input: input.scanInput,
|
|
1422
|
+
source: {
|
|
1423
|
+
repository: input.repository ?? null,
|
|
1424
|
+
publishedSiteUrl: input.publishedSiteUrl ?? null
|
|
1425
|
+
},
|
|
1426
|
+
confidence: {
|
|
1427
|
+
level: "low",
|
|
1428
|
+
score: 5,
|
|
1429
|
+
reasons: ["Repository source could not be inspected."]
|
|
1430
|
+
},
|
|
1431
|
+
applicability: {
|
|
1432
|
+
status: "unknown",
|
|
1433
|
+
label: "Scan unavailable",
|
|
1434
|
+
reasons: [input.message]
|
|
1435
|
+
},
|
|
1436
|
+
project: {
|
|
1437
|
+
framework: "unknown",
|
|
1438
|
+
frameworkVersion: null,
|
|
1439
|
+
packageManager: "unknown",
|
|
1440
|
+
primaryLanguage: "unknown",
|
|
1441
|
+
hasTypeScript: false,
|
|
1442
|
+
hasTailwind: false,
|
|
1443
|
+
hasDecantr: false,
|
|
1444
|
+
packageName: null
|
|
1445
|
+
},
|
|
1446
|
+
routes: { strategy: "none", count: 0, items: [] },
|
|
1447
|
+
components: { pageCount: 0, componentCount: 0, directories: [] },
|
|
1448
|
+
styling: {
|
|
1449
|
+
approach: "unknown",
|
|
1450
|
+
configFile: null,
|
|
1451
|
+
cssVariableCount: 0,
|
|
1452
|
+
colorTokenCount: 0,
|
|
1453
|
+
darkMode: false,
|
|
1454
|
+
themeSignals: []
|
|
1455
|
+
},
|
|
1456
|
+
staticHosting: {
|
|
1457
|
+
githubPagesLikely: false,
|
|
1458
|
+
evidence: [],
|
|
1459
|
+
homepageUrl: null,
|
|
1460
|
+
basePath: null,
|
|
1461
|
+
hashRouting: false
|
|
1462
|
+
},
|
|
1463
|
+
assistant: { ruleFiles: [] },
|
|
1464
|
+
pagesProbe: input.pagesProbe ?? null,
|
|
1465
|
+
findings: [
|
|
1466
|
+
{
|
|
1467
|
+
id: "source-unavailable",
|
|
1468
|
+
severity: "error",
|
|
1469
|
+
title: input.title,
|
|
1470
|
+
message: input.message,
|
|
1471
|
+
evidence: input.evidence ?? []
|
|
1472
|
+
}
|
|
1473
|
+
],
|
|
1474
|
+
recommendedCommands: ["npx @decantr/cli scan"],
|
|
1475
|
+
privacy: {
|
|
1476
|
+
sourceUploaded: input.scanInput.kind !== "local",
|
|
1477
|
+
persistedByDecantr: false,
|
|
1478
|
+
notes: [
|
|
1479
|
+
"Decantr did not persist this report.",
|
|
1480
|
+
"No dependencies were installed, no scripts were executed, and no pull requests were opened."
|
|
1481
|
+
]
|
|
1482
|
+
}
|
|
1483
|
+
};
|
|
1484
|
+
}
|
|
1485
|
+
function decodeHtml(value) {
|
|
1486
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'").trim();
|
|
1487
|
+
}
|
|
1488
|
+
function extractHtmlAttribute(tag, attr) {
|
|
1489
|
+
const match = tag.match(new RegExp(`${attr}\\s*=\\s*["']([^"']+)["']`, "i"));
|
|
1490
|
+
return match?.[1] ? decodeHtml(match[1]) : null;
|
|
1491
|
+
}
|
|
1492
|
+
async function probePublishedSite(url, options = {}) {
|
|
1493
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
1494
|
+
const controller = new AbortController();
|
|
1495
|
+
const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? 6e3);
|
|
1496
|
+
try {
|
|
1497
|
+
const response = await fetchImpl(url, {
|
|
1498
|
+
method: "GET",
|
|
1499
|
+
redirect: "follow",
|
|
1500
|
+
signal: controller.signal,
|
|
1501
|
+
headers: {
|
|
1502
|
+
Accept: "text/html,application/xhtml+xml",
|
|
1503
|
+
"User-Agent": "Decantr-Scan/1.0 (+https://decantr.ai/scan)"
|
|
1504
|
+
}
|
|
1505
|
+
});
|
|
1506
|
+
const text = (await response.text()).slice(0, MAX_FILE_READ_BYTES);
|
|
1507
|
+
const titleMatch = text.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
|
|
1508
|
+
const descriptionTag = text.match(/<meta\b[^>]*(?:name|property)=["']description["'][^>]*>/i)?.[0] ?? null;
|
|
1509
|
+
const canonicalTag = text.match(/<link\b[^>]*rel=["']canonical["'][^>]*>/i)?.[0] ?? null;
|
|
1510
|
+
const assetSamples = [...text.matchAll(/\b(?:src|href)=["']([^"']+)["']/gi)].map((match) => match[1]).filter((value) => Boolean(value)).slice(0, 40);
|
|
1511
|
+
const routingHints = [];
|
|
1512
|
+
if (text.includes("/#/") || text.includes("#/")) routingHints.push("hash route URLs present");
|
|
1513
|
+
if (text.includes("data-reactroot") || text.includes('id="root"') || text.includes("id='root'")) {
|
|
1514
|
+
routingHints.push("client app mount detected");
|
|
1515
|
+
}
|
|
1516
|
+
return {
|
|
1517
|
+
url,
|
|
1518
|
+
finalUrl: response.url || url,
|
|
1519
|
+
checked: true,
|
|
1520
|
+
reachable: response.ok,
|
|
1521
|
+
status: response.status,
|
|
1522
|
+
title: titleMatch?.[1] ? decodeHtml(titleMatch[1].replace(/\s+/g, " ")) : null,
|
|
1523
|
+
description: descriptionTag ? extractHtmlAttribute(descriptionTag, "content") : null,
|
|
1524
|
+
canonicalUrl: canonicalTag ? extractHtmlAttribute(canonicalTag, "href") : null,
|
|
1525
|
+
assetHints: {
|
|
1526
|
+
rootRelative: assetSamples.filter((item) => item.startsWith("/") && !item.startsWith("//")).length,
|
|
1527
|
+
relative: assetSamples.filter((item) => !item.startsWith("/") && !/^https?:\/\//.test(item)).length,
|
|
1528
|
+
absolute: assetSamples.filter((item) => /^https?:\/\//.test(item) || item.startsWith("//")).length,
|
|
1529
|
+
samples: assetSamples.slice(0, 8)
|
|
1530
|
+
},
|
|
1531
|
+
routingHints,
|
|
1532
|
+
error: null
|
|
1533
|
+
};
|
|
1534
|
+
} catch (error) {
|
|
1535
|
+
return {
|
|
1536
|
+
url,
|
|
1537
|
+
finalUrl: null,
|
|
1538
|
+
checked: true,
|
|
1539
|
+
reachable: false,
|
|
1540
|
+
status: null,
|
|
1541
|
+
title: null,
|
|
1542
|
+
description: null,
|
|
1543
|
+
canonicalUrl: null,
|
|
1544
|
+
assetHints: { rootRelative: 0, relative: 0, absolute: 0, samples: [] },
|
|
1545
|
+
routingHints: [],
|
|
1546
|
+
error: error instanceof Error ? error.message : "Published site probe failed."
|
|
1547
|
+
};
|
|
1548
|
+
} finally {
|
|
1549
|
+
clearTimeout(timeout);
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
function resolveGitHubScanInput(input) {
|
|
1553
|
+
let url;
|
|
1554
|
+
try {
|
|
1555
|
+
url = new URL(input.trim());
|
|
1556
|
+
} catch {
|
|
1557
|
+
throw new Error("Enter a valid GitHub repository URL or GitHub Pages URL.");
|
|
1558
|
+
}
|
|
1559
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
|
1560
|
+
throw new Error("Scan URLs must use http or https.");
|
|
1561
|
+
}
|
|
1562
|
+
const host = url.hostname.toLowerCase();
|
|
1563
|
+
if (host === "github.com" || host === "www.github.com") {
|
|
1564
|
+
const [owner, repoSegment] = url.pathname.split("/").filter(Boolean);
|
|
1565
|
+
if (!owner || !repoSegment) {
|
|
1566
|
+
throw new Error("GitHub repository URLs must include owner and repository.");
|
|
1567
|
+
}
|
|
1568
|
+
const repo = repoSegment.replace(/\.git$/, "");
|
|
1569
|
+
return {
|
|
1570
|
+
inputKind: "github-repo",
|
|
1571
|
+
normalizedInput: `https://github.com/${owner}/${repo}`,
|
|
1572
|
+
repository: {
|
|
1573
|
+
owner,
|
|
1574
|
+
repo,
|
|
1575
|
+
url: `https://github.com/${owner}/${repo}`
|
|
1576
|
+
},
|
|
1577
|
+
publishedSiteUrl: `https://${owner}.github.io/${repo}/`,
|
|
1578
|
+
warnings: url.pathname.split("/").filter(Boolean).length > 2 ? ["Extra GitHub URL path segments were ignored."] : []
|
|
1579
|
+
};
|
|
1580
|
+
}
|
|
1581
|
+
if (host.endsWith(".github.io")) {
|
|
1582
|
+
const owner = host.slice(0, -".github.io".length);
|
|
1583
|
+
const segments = url.pathname.split("/").filter(Boolean);
|
|
1584
|
+
const repo = segments[0] ?? `${owner}.github.io`;
|
|
1585
|
+
const publishedSiteUrl = segments[0] ? `https://${owner}.github.io/${repo}/` : `https://${owner}.github.io/`;
|
|
1586
|
+
return {
|
|
1587
|
+
inputKind: "github-pages",
|
|
1588
|
+
normalizedInput: publishedSiteUrl,
|
|
1589
|
+
repository: {
|
|
1590
|
+
owner,
|
|
1591
|
+
repo,
|
|
1592
|
+
url: `https://github.com/${owner}/${repo}`
|
|
1593
|
+
},
|
|
1594
|
+
publishedSiteUrl,
|
|
1595
|
+
warnings: segments.length > 1 ? ["Only the first GitHub Pages path segment was used to infer the repository."] : []
|
|
1596
|
+
};
|
|
1597
|
+
}
|
|
1598
|
+
throw new Error("V1 hosted scans support GitHub repositories and GitHub Pages URLs.");
|
|
1599
|
+
}
|
|
1600
|
+
|
|
757
1601
|
// src/index.ts
|
|
758
1602
|
var VERIFICATION_SCHEMA_URLS = {
|
|
759
1603
|
common: "https://decantr.ai/schemas/verification-report.common.v1.json",
|
|
@@ -761,6 +1605,7 @@ var VERIFICATION_SCHEMA_URLS = {
|
|
|
761
1605
|
projectHealth: "https://decantr.ai/schemas/project-health-report.v1.json",
|
|
762
1606
|
decantrCi: "https://decantr.ai/schemas/decantr-ci-report.v1.json",
|
|
763
1607
|
evidenceBundle: "https://decantr.ai/schemas/evidence-bundle.v1.json",
|
|
1608
|
+
scanReport: "https://decantr.ai/schemas/scan-report.v1.json",
|
|
764
1609
|
workspaceHealth: "https://decantr.ai/schemas/workspace-health-report.v1.json",
|
|
765
1610
|
fileCritique: "https://decantr.ai/schemas/file-critique-report.v1.json",
|
|
766
1611
|
showcaseShortlist: "https://decantr.ai/schemas/showcase-shortlist-report.v1.json"
|
|
@@ -770,16 +1615,16 @@ function hashString(value) {
|
|
|
770
1615
|
return createHash("sha256").update(value).digest("hex").slice(0, 16);
|
|
771
1616
|
}
|
|
772
1617
|
function hashFile(path) {
|
|
773
|
-
if (!
|
|
1618
|
+
if (!existsSync3(path)) return null;
|
|
774
1619
|
try {
|
|
775
|
-
return createHash("sha256").update(
|
|
1620
|
+
return createHash("sha256").update(readFileSync3(path)).digest("hex");
|
|
776
1621
|
} catch {
|
|
777
1622
|
return null;
|
|
778
1623
|
}
|
|
779
1624
|
}
|
|
780
1625
|
function provenanceEntry(projectRoot, relativePath) {
|
|
781
|
-
const path =
|
|
782
|
-
if (!
|
|
1626
|
+
const path = join3(projectRoot, relativePath);
|
|
1627
|
+
if (!existsSync3(path)) {
|
|
783
1628
|
return {
|
|
784
1629
|
path: relativePath,
|
|
785
1630
|
present: false,
|
|
@@ -789,7 +1634,7 @@ function provenanceEntry(projectRoot, relativePath) {
|
|
|
789
1634
|
}
|
|
790
1635
|
let generatedAt = null;
|
|
791
1636
|
try {
|
|
792
|
-
generatedAt =
|
|
1637
|
+
generatedAt = statSync3(path).mtime.toISOString();
|
|
793
1638
|
} catch {
|
|
794
1639
|
generatedAt = null;
|
|
795
1640
|
}
|
|
@@ -801,7 +1646,7 @@ function provenanceEntry(projectRoot, relativePath) {
|
|
|
801
1646
|
};
|
|
802
1647
|
}
|
|
803
1648
|
function provenanceForPath(projectRoot, path) {
|
|
804
|
-
const rel =
|
|
1649
|
+
const rel = isAbsolute2(path) ? relative2(projectRoot, path).replace(/\\/g, "/") : path;
|
|
805
1650
|
return provenanceEntry(projectRoot, rel && !rel.startsWith("..") ? rel : basename(path));
|
|
806
1651
|
}
|
|
807
1652
|
function redactEvidenceText(projectRoot, value) {
|
|
@@ -825,7 +1670,7 @@ function createContractAssertions(projectRoot, audit) {
|
|
|
825
1670
|
const assertions = [];
|
|
826
1671
|
const essence = audit?.essence;
|
|
827
1672
|
const v4 = essence && isV4(essence) ? essence : null;
|
|
828
|
-
const contextDir =
|
|
1673
|
+
const contextDir = join3(projectRoot, ".decantr", "context");
|
|
829
1674
|
const packManifest = audit?.packManifest ?? null;
|
|
830
1675
|
assertions.push(
|
|
831
1676
|
assertion({
|
|
@@ -835,7 +1680,7 @@ function createContractAssertions(projectRoot, audit) {
|
|
|
835
1680
|
rule: "essence-present",
|
|
836
1681
|
status: essence ? "passed" : "failed",
|
|
837
1682
|
message: essence ? "Decantr Essence contract is present." : "No Decantr Essence contract was found.",
|
|
838
|
-
evidence: [redactEvidenceText(projectRoot,
|
|
1683
|
+
evidence: [redactEvidenceText(projectRoot, join3(projectRoot, "decantr.essence.json"))],
|
|
839
1684
|
suggestedFix: essence ? void 0 : "Run `decantr init` or restore decantr.essence.json."
|
|
840
1685
|
})
|
|
841
1686
|
);
|
|
@@ -907,7 +1752,7 @@ function createContractAssertions(projectRoot, audit) {
|
|
|
907
1752
|
rule: "pack-manifest-present",
|
|
908
1753
|
status: packManifest ? "passed" : "failed",
|
|
909
1754
|
message: packManifest ? "Compiled execution pack manifest is present." : "Compiled execution pack manifest is missing.",
|
|
910
|
-
evidence: [redactEvidenceText(projectRoot,
|
|
1755
|
+
evidence: [redactEvidenceText(projectRoot, join3(contextDir, "pack-manifest.json"))],
|
|
911
1756
|
suggestedFix: packManifest ? void 0 : "Run `decantr registry compile-packs decantr.essence.json --write-context` to hydrate the full context pack bundle."
|
|
912
1757
|
})
|
|
913
1758
|
);
|
|
@@ -919,21 +1764,21 @@ function createContractAssertions(projectRoot, audit) {
|
|
|
919
1764
|
rule: "review-pack-present",
|
|
920
1765
|
status: audit?.reviewPack ? "passed" : "failed",
|
|
921
1766
|
message: audit?.reviewPack ? "Compiled review pack is present." : "Compiled review pack is missing.",
|
|
922
|
-
evidence: [redactEvidenceText(projectRoot,
|
|
1767
|
+
evidence: [redactEvidenceText(projectRoot, join3(contextDir, "review-pack.json"))],
|
|
923
1768
|
suggestedFix: audit?.reviewPack ? void 0 : "Run `decantr registry compile-packs decantr.essence.json --write-context` so critique has the compiled review contract."
|
|
924
1769
|
})
|
|
925
1770
|
);
|
|
926
|
-
const tokensPath =
|
|
1771
|
+
const tokensPath = join3(projectRoot, "src", "styles", "tokens.css");
|
|
927
1772
|
assertions.push(
|
|
928
1773
|
assertion({
|
|
929
1774
|
id: "contract.design-token.tokens-file",
|
|
930
1775
|
category: "design-token",
|
|
931
|
-
severity:
|
|
1776
|
+
severity: existsSync3(tokensPath) ? "info" : "warn",
|
|
932
1777
|
rule: "tokens-file-present",
|
|
933
|
-
status:
|
|
934
|
-
message:
|
|
1778
|
+
status: existsSync3(tokensPath) ? "passed" : "failed",
|
|
1779
|
+
message: existsSync3(tokensPath) ? "Decantr token CSS file is present." : "Decantr token CSS file was not found.",
|
|
935
1780
|
evidence: [redactEvidenceText(projectRoot, tokensPath)],
|
|
936
|
-
suggestedFix:
|
|
1781
|
+
suggestedFix: existsSync3(tokensPath) ? void 0 : "Run `decantr refresh` or map Decantr tokens into the existing styling system."
|
|
937
1782
|
})
|
|
938
1783
|
);
|
|
939
1784
|
return assertions;
|
|
@@ -1038,21 +1883,21 @@ function scoreRatio(numerator, denominator) {
|
|
|
1038
1883
|
return Math.min(5, Math.max(1, Math.round(numerator / denominator * 5)));
|
|
1039
1884
|
}
|
|
1040
1885
|
function readJsonIfExists(path) {
|
|
1041
|
-
if (!
|
|
1886
|
+
if (!existsSync3(path)) return null;
|
|
1042
1887
|
try {
|
|
1043
|
-
return JSON.parse(
|
|
1888
|
+
return JSON.parse(readFileSync3(path, "utf-8"));
|
|
1044
1889
|
} catch {
|
|
1045
1890
|
return null;
|
|
1046
1891
|
}
|
|
1047
1892
|
}
|
|
1048
1893
|
function loadReviewPack(projectRoot) {
|
|
1049
1894
|
return readJsonIfExists(
|
|
1050
|
-
|
|
1895
|
+
join3(projectRoot, ".decantr", "context", "review-pack.json")
|
|
1051
1896
|
);
|
|
1052
1897
|
}
|
|
1053
1898
|
function loadPackManifest(projectRoot) {
|
|
1054
1899
|
return readJsonIfExists(
|
|
1055
|
-
|
|
1900
|
+
join3(projectRoot, ".decantr", "context", "pack-manifest.json")
|
|
1056
1901
|
);
|
|
1057
1902
|
}
|
|
1058
1903
|
function collectPackManifestReferences(packManifest) {
|
|
@@ -1101,14 +1946,14 @@ function collectPackManifestReferences(packManifest) {
|
|
|
1101
1946
|
}
|
|
1102
1947
|
function collectMissingPackManifestFiles(projectRoot, packManifest = loadPackManifest(projectRoot)) {
|
|
1103
1948
|
if (!packManifest) return [];
|
|
1104
|
-
const contextDir =
|
|
1949
|
+
const contextDir = join3(projectRoot, ".decantr", "context");
|
|
1105
1950
|
const missing = [];
|
|
1106
1951
|
for (const reference of collectPackManifestReferences(packManifest)) {
|
|
1107
1952
|
for (const field of ["markdown", "json"]) {
|
|
1108
1953
|
const fileName = reference[field];
|
|
1109
1954
|
if (!fileName) continue;
|
|
1110
|
-
const absolutePath =
|
|
1111
|
-
if (
|
|
1955
|
+
const absolutePath = join3(contextDir, fileName);
|
|
1956
|
+
if (existsSync3(absolutePath)) continue;
|
|
1112
1957
|
missing.push({
|
|
1113
1958
|
entryId: reference.entryId,
|
|
1114
1959
|
kind: reference.kind,
|
|
@@ -1122,13 +1967,13 @@ function collectMissingPackManifestFiles(projectRoot, packManifest = loadPackMan
|
|
|
1122
1967
|
}
|
|
1123
1968
|
function readTextIfExists(path) {
|
|
1124
1969
|
try {
|
|
1125
|
-
return
|
|
1970
|
+
return existsSync3(path) ? readFileSync3(path, "utf-8") : "";
|
|
1126
1971
|
} catch {
|
|
1127
1972
|
return "";
|
|
1128
1973
|
}
|
|
1129
1974
|
}
|
|
1130
1975
|
function readProjectAdoptionMode(projectRoot) {
|
|
1131
|
-
const projectJson = readJsonIfExists(
|
|
1976
|
+
const projectJson = readJsonIfExists(join3(projectRoot, ".decantr", "project.json"));
|
|
1132
1977
|
const adoptionMode = projectJson?.initialized?.adoptionMode;
|
|
1133
1978
|
return typeof adoptionMode === "string" ? adoptionMode : null;
|
|
1134
1979
|
}
|
|
@@ -1214,7 +2059,7 @@ function collectProjectSourceFiles(projectRoot) {
|
|
|
1214
2059
|
"hooks",
|
|
1215
2060
|
"providers",
|
|
1216
2061
|
"server"
|
|
1217
|
-
].map((dir) =>
|
|
2062
|
+
].map((dir) => join3(projectRoot, dir)).filter((dir) => existsSync3(dir));
|
|
1218
2063
|
const rootFileCandidates = [
|
|
1219
2064
|
"middleware.ts",
|
|
1220
2065
|
"middleware.tsx",
|
|
@@ -1228,7 +2073,7 @@ function collectProjectSourceFiles(projectRoot) {
|
|
|
1228
2073
|
"proxy.jsx",
|
|
1229
2074
|
"proxy.mts",
|
|
1230
2075
|
"proxy.cts"
|
|
1231
|
-
].map((file) =>
|
|
2076
|
+
].map((file) => join3(projectRoot, file)).filter((file) => existsSync3(file));
|
|
1232
2077
|
const files = /* @__PURE__ */ new Set();
|
|
1233
2078
|
const ignoredDirNames = /* @__PURE__ */ new Set([
|
|
1234
2079
|
"node_modules",
|
|
@@ -1239,9 +2084,9 @@ function collectProjectSourceFiles(projectRoot) {
|
|
|
1239
2084
|
"coverage"
|
|
1240
2085
|
]);
|
|
1241
2086
|
const walk = (dir) => {
|
|
1242
|
-
for (const entry of
|
|
2087
|
+
for (const entry of readdirSync3(dir, { withFileTypes: true })) {
|
|
1243
2088
|
if (ignoredDirNames.has(entry.name)) continue;
|
|
1244
|
-
const absolutePath =
|
|
2089
|
+
const absolutePath = join3(dir, entry.name);
|
|
1245
2090
|
if (entry.isDirectory()) {
|
|
1246
2091
|
walk(absolutePath);
|
|
1247
2092
|
continue;
|
|
@@ -1272,7 +2117,7 @@ function isAuditableStyleFile(filePath) {
|
|
|
1272
2117
|
return /\.css$/i.test(filePath);
|
|
1273
2118
|
}
|
|
1274
2119
|
function collectProjectStyleFiles(projectRoot) {
|
|
1275
|
-
const candidates = ["src", "app", "styles"].map((dir) =>
|
|
2120
|
+
const candidates = ["src", "app", "styles"].map((dir) => join3(projectRoot, dir)).filter((dir) => existsSync3(dir));
|
|
1276
2121
|
const files = /* @__PURE__ */ new Set();
|
|
1277
2122
|
const ignoredDirNames = /* @__PURE__ */ new Set([
|
|
1278
2123
|
"node_modules",
|
|
@@ -1283,9 +2128,9 @@ function collectProjectStyleFiles(projectRoot) {
|
|
|
1283
2128
|
"coverage"
|
|
1284
2129
|
]);
|
|
1285
2130
|
const walk = (dir) => {
|
|
1286
|
-
for (const entry of
|
|
2131
|
+
for (const entry of readdirSync3(dir, { withFileTypes: true })) {
|
|
1287
2132
|
if (ignoredDirNames.has(entry.name)) continue;
|
|
1288
|
-
const absolutePath =
|
|
2133
|
+
const absolutePath = join3(dir, entry.name);
|
|
1289
2134
|
if (entry.isDirectory()) {
|
|
1290
2135
|
walk(absolutePath);
|
|
1291
2136
|
continue;
|
|
@@ -1359,7 +2204,7 @@ function collectRuntimeImportSpecifiers(entry) {
|
|
|
1359
2204
|
function resolveSourceImportTarget(projectRoot, sourceAbsolutePath, specifier) {
|
|
1360
2205
|
let basePath = null;
|
|
1361
2206
|
if (specifier.startsWith("@/")) {
|
|
1362
|
-
basePath =
|
|
2207
|
+
basePath = join3(projectRoot, "src", specifier.slice(2));
|
|
1363
2208
|
} else if (specifier.startsWith("./") || specifier.startsWith("../")) {
|
|
1364
2209
|
basePath = resolve(dirname(sourceAbsolutePath), specifier);
|
|
1365
2210
|
}
|
|
@@ -1372,14 +2217,14 @@ function resolveSourceImportTarget(projectRoot, sourceAbsolutePath, specifier) {
|
|
|
1372
2217
|
`${basePath}.jsx`,
|
|
1373
2218
|
`${basePath}.mts`,
|
|
1374
2219
|
`${basePath}.cts`,
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
2220
|
+
join3(basePath, "index.ts"),
|
|
2221
|
+
join3(basePath, "index.tsx"),
|
|
2222
|
+
join3(basePath, "index.js"),
|
|
2223
|
+
join3(basePath, "index.jsx")
|
|
1379
2224
|
];
|
|
1380
2225
|
for (const candidate of candidates) {
|
|
1381
|
-
if (
|
|
1382
|
-
return
|
|
2226
|
+
if (existsSync3(candidate) && isAuditableSourceFile(candidate)) {
|
|
2227
|
+
return relative2(projectRoot, candidate) || candidate;
|
|
1383
2228
|
}
|
|
1384
2229
|
}
|
|
1385
2230
|
return null;
|
|
@@ -1428,8 +2273,8 @@ function auditProjectSourceTree(projectRoot) {
|
|
|
1428
2273
|
const sourceFiles = collectProjectSourceFiles(projectRoot);
|
|
1429
2274
|
const sourceEntries = sourceFiles.map((sourceFile) => ({
|
|
1430
2275
|
absolutePath: sourceFile,
|
|
1431
|
-
relativePath:
|
|
1432
|
-
code:
|
|
2276
|
+
relativePath: relative2(projectRoot, sourceFile) || sourceFile,
|
|
2277
|
+
code: readFileSync3(sourceFile, "utf-8")
|
|
1433
2278
|
})).filter((entry) => !isNonProductionSourceAuditFile(entry.relativePath));
|
|
1434
2279
|
const clientReachableFiles = collectClientReachableSourceFiles(projectRoot, sourceEntries);
|
|
1435
2280
|
const summary = {
|
|
@@ -1742,8 +2587,8 @@ function auditProjectStyleContracts(projectRoot) {
|
|
|
1742
2587
|
reducedMotionSignals: createSourceAuditBucket()
|
|
1743
2588
|
};
|
|
1744
2589
|
for (const styleFile of styleFiles) {
|
|
1745
|
-
const relativePath =
|
|
1746
|
-
const css =
|
|
2590
|
+
const relativePath = relative2(projectRoot, styleFile) || styleFile;
|
|
2591
|
+
const css = readFileSync3(styleFile, "utf-8");
|
|
1747
2592
|
recordSourceAudit(summary.focusVisibleSignals, relativePath, countFocusVisibleSignals(css));
|
|
1748
2593
|
recordSourceAudit(summary.reducedMotionSignals, relativePath, countReducedMotionSignals(css));
|
|
1749
2594
|
}
|
|
@@ -1752,15 +2597,15 @@ function auditProjectStyleContracts(projectRoot) {
|
|
|
1752
2597
|
function buildRegistryContext(projectRoot) {
|
|
1753
2598
|
const themeRegistry = /* @__PURE__ */ new Map();
|
|
1754
2599
|
const patternRegistry = /* @__PURE__ */ new Map();
|
|
1755
|
-
const cacheDir =
|
|
1756
|
-
const customDir =
|
|
1757
|
-
const cachedThemesDir =
|
|
2600
|
+
const cacheDir = join3(projectRoot, ".decantr", "cache");
|
|
2601
|
+
const customDir = join3(projectRoot, ".decantr", "custom");
|
|
2602
|
+
const cachedThemesDir = join3(cacheDir, "@official", "themes");
|
|
1758
2603
|
try {
|
|
1759
|
-
if (
|
|
1760
|
-
for (const file of
|
|
2604
|
+
if (existsSync3(cachedThemesDir)) {
|
|
2605
|
+
for (const file of readdirSync3(cachedThemesDir).filter(
|
|
1761
2606
|
(name) => name.endsWith(".json") && name !== "index.json"
|
|
1762
2607
|
)) {
|
|
1763
|
-
const data = JSON.parse(
|
|
2608
|
+
const data = JSON.parse(readFileSync3(join3(cachedThemesDir, file), "utf-8"));
|
|
1764
2609
|
if (data.id && !themeRegistry.has(data.id)) {
|
|
1765
2610
|
themeRegistry.set(data.id, { modes: data.modes || ["light", "dark"] });
|
|
1766
2611
|
}
|
|
@@ -1768,11 +2613,11 @@ function buildRegistryContext(projectRoot) {
|
|
|
1768
2613
|
}
|
|
1769
2614
|
} catch {
|
|
1770
2615
|
}
|
|
1771
|
-
const customThemesDir =
|
|
2616
|
+
const customThemesDir = join3(customDir, "themes");
|
|
1772
2617
|
try {
|
|
1773
|
-
if (
|
|
1774
|
-
for (const file of
|
|
1775
|
-
const data = JSON.parse(
|
|
2618
|
+
if (existsSync3(customThemesDir)) {
|
|
2619
|
+
for (const file of readdirSync3(customThemesDir).filter((name) => name.endsWith(".json"))) {
|
|
2620
|
+
const data = JSON.parse(readFileSync3(join3(customThemesDir, file), "utf-8"));
|
|
1776
2621
|
if (data.id) {
|
|
1777
2622
|
themeRegistry.set(`custom:${data.id}`, { modes: data.modes || ["light", "dark"] });
|
|
1778
2623
|
}
|
|
@@ -1780,13 +2625,13 @@ function buildRegistryContext(projectRoot) {
|
|
|
1780
2625
|
}
|
|
1781
2626
|
} catch {
|
|
1782
2627
|
}
|
|
1783
|
-
const cachedPatternsDir =
|
|
2628
|
+
const cachedPatternsDir = join3(cacheDir, "@official", "patterns");
|
|
1784
2629
|
try {
|
|
1785
|
-
if (
|
|
1786
|
-
for (const file of
|
|
2630
|
+
if (existsSync3(cachedPatternsDir)) {
|
|
2631
|
+
for (const file of readdirSync3(cachedPatternsDir).filter(
|
|
1787
2632
|
(name) => name.endsWith(".json") && name !== "index.json"
|
|
1788
2633
|
)) {
|
|
1789
|
-
const data = JSON.parse(
|
|
2634
|
+
const data = JSON.parse(readFileSync3(join3(cachedPatternsDir, file), "utf-8"));
|
|
1790
2635
|
if (data.id && !patternRegistry.has(data.id)) {
|
|
1791
2636
|
patternRegistry.set(data.id, data);
|
|
1792
2637
|
}
|
|
@@ -2100,8 +2945,8 @@ function extractFailedRouteDocumentHardening(runtimeAudit) {
|
|
|
2100
2945
|
return runtimeAudit.failures.filter((failure) => failure.startsWith("route-document-hardening-failed:")).map((failure) => normalizeRouteHint2(failure.slice("route-document-hardening-failed:".length))).filter(Boolean);
|
|
2101
2946
|
}
|
|
2102
2947
|
function appendRuntimeAuditFindings(findings, runtimeAudit, projectRoot, topology, sourceAudit) {
|
|
2103
|
-
const distPath =
|
|
2104
|
-
const indexPath =
|
|
2948
|
+
const distPath = join3(projectRoot, "dist");
|
|
2949
|
+
const indexPath = join3(distPath, "index.html");
|
|
2105
2950
|
const isFrameworkBuildOutput = runtimeAudit.failures.includes("next-build-output");
|
|
2106
2951
|
if (!runtimeAudit.distPresent) {
|
|
2107
2952
|
findings.push(
|
|
@@ -2648,20 +3493,20 @@ function appendSourceAuditFindings(findings, sourceAudit, essence, reviewPack, a
|
|
|
2648
3493
|
if (sourceAudit.filesChecked === 0) {
|
|
2649
3494
|
return;
|
|
2650
3495
|
}
|
|
2651
|
-
const
|
|
3496
|
+
const isProjectOwnedStyling = adoptionMode === "contract-only" || adoptionMode === "style-bridge";
|
|
2652
3497
|
if (sourceAudit.inlineStyles.count > 0) {
|
|
2653
3498
|
findings.push(
|
|
2654
3499
|
makeFinding({
|
|
2655
3500
|
id: "source-inline-styles-present",
|
|
2656
3501
|
category: "Source Audit",
|
|
2657
3502
|
severity: "warn",
|
|
2658
|
-
message:
|
|
3503
|
+
message: isProjectOwnedStyling ? "Source files contain inline style attributes; project-owned styling projects should route static visual decisions through local law or the style bridge." : "Source files still contain disallowed inline style attributes, which undermines the compiled treatment contract.",
|
|
2659
3504
|
evidence: buildSourceAuditEvidence(
|
|
2660
3505
|
sourceAudit,
|
|
2661
3506
|
sourceAudit.inlineStyles,
|
|
2662
3507
|
"Disallowed inline style attributes"
|
|
2663
3508
|
),
|
|
2664
|
-
suggestedFix:
|
|
3509
|
+
suggestedFix: isProjectOwnedStyling ? "Move static visual styling into the app design system, Tailwind/theme tokens, component variants, or accepted local rules. Keep inline style only for truly dynamic geometry." : "Move static visual styling into treatments, atoms, or design-token-backed classes. Inline style remains acceptable for Decantr CSS-variable writes and truly dynamic geometry."
|
|
2665
3510
|
})
|
|
2666
3511
|
);
|
|
2667
3512
|
}
|
|
@@ -2671,17 +3516,17 @@ function appendSourceAuditFindings(findings, sourceAudit, essence, reviewPack, a
|
|
|
2671
3516
|
id: "source-component-style-tags-present",
|
|
2672
3517
|
category: "Source Audit",
|
|
2673
3518
|
severity: "warn",
|
|
2674
|
-
message:
|
|
3519
|
+
message: isProjectOwnedStyling ? "Source files inject component-scoped style tags or dynamic style elements, which makes project-owned styling rules harder to enforce." : "Source files inject component-scoped style tags or dynamic style elements, which bypass the compiled Decantr layer contract.",
|
|
2675
3520
|
evidence: buildSourceAuditEvidence(
|
|
2676
3521
|
sourceAudit,
|
|
2677
3522
|
sourceAudit.componentStyleTags,
|
|
2678
3523
|
"Component-level style tag signals"
|
|
2679
3524
|
),
|
|
2680
|
-
suggestedFix:
|
|
3525
|
+
suggestedFix: isProjectOwnedStyling ? "Move shared keyframes, media queries, and visual rules into the project stylesheet, component library, or accepted local pattern/rule manifest." : "Move shared keyframes, media queries, and visual rules into global.css or treatments.css so styling stays inside the reviewed Decantr layer stack."
|
|
2681
3526
|
})
|
|
2682
3527
|
);
|
|
2683
3528
|
}
|
|
2684
|
-
if (!
|
|
3529
|
+
if (!isProjectOwnedStyling && sourceAudit.localCssRuntimeSignals.count > 0) {
|
|
2685
3530
|
findings.push(
|
|
2686
3531
|
makeFinding({
|
|
2687
3532
|
id: "source-local-css-runtime-stub-present",
|
|
@@ -3780,15 +4625,15 @@ function appendStyleContractFindings(findings, styleAudit, essence) {
|
|
|
3780
4625
|
}
|
|
3781
4626
|
}
|
|
3782
4627
|
async function auditProject(projectRoot) {
|
|
3783
|
-
const essencePath =
|
|
4628
|
+
const essencePath = join3(projectRoot, "decantr.essence.json");
|
|
3784
4629
|
const findings = [];
|
|
3785
4630
|
const reviewPack = loadReviewPack(projectRoot);
|
|
3786
4631
|
const packManifest = loadPackManifest(projectRoot);
|
|
3787
4632
|
const adoptionMode = readProjectAdoptionMode(projectRoot);
|
|
3788
|
-
const packHydrationOptional = adoptionMode === "contract-only";
|
|
4633
|
+
const packHydrationOptional = adoptionMode === "contract-only" || adoptionMode === "style-bridge";
|
|
3789
4634
|
const packHydrationSeverity = packHydrationOptional ? "info" : "warn";
|
|
3790
4635
|
const runtimeAudit = emptyRuntimeAudit();
|
|
3791
|
-
if (!
|
|
4636
|
+
if (!existsSync3(essencePath)) {
|
|
3792
4637
|
findings.push(
|
|
3793
4638
|
makeFinding({
|
|
3794
4639
|
id: "essence-missing",
|
|
@@ -3823,7 +4668,7 @@ async function auditProject(projectRoot) {
|
|
|
3823
4668
|
}
|
|
3824
4669
|
let essence = null;
|
|
3825
4670
|
try {
|
|
3826
|
-
essence = JSON.parse(
|
|
4671
|
+
essence = JSON.parse(readFileSync3(essencePath, "utf-8"));
|
|
3827
4672
|
} catch (error) {
|
|
3828
4673
|
findings.push(
|
|
3829
4674
|
makeFinding({
|
|
@@ -3865,8 +4710,8 @@ async function auditProject(projectRoot) {
|
|
|
3865
4710
|
id: "pack-manifest-missing",
|
|
3866
4711
|
category: "Execution Packs",
|
|
3867
4712
|
severity: packHydrationSeverity,
|
|
3868
|
-
message: packHydrationOptional ? "Compiled execution pack manifest is not hydrated yet; this is optional for contract-only Brownfield adoption." : "Compiled execution pack manifest is missing.",
|
|
3869
|
-
evidence: [
|
|
4713
|
+
message: packHydrationOptional ? "Compiled execution pack manifest is not hydrated yet; this is optional for contract-only/style-bridge Brownfield adoption." : "Compiled execution pack manifest is missing.",
|
|
4714
|
+
evidence: [join3(projectRoot, ".decantr", "context", "pack-manifest.json")],
|
|
3870
4715
|
suggestedFix: packHydrationOptional ? "Optional: run `decantr registry compile-packs decantr.essence.json --write-context` when you want hosted page packs and review packs for richer assistant context." : "Run `decantr registry compile-packs decantr.essence.json --write-context` to hydrate scaffold, review, mutation, section, and page packs."
|
|
3871
4716
|
})
|
|
3872
4717
|
);
|
|
@@ -3929,8 +4774,8 @@ async function auditProject(projectRoot) {
|
|
|
3929
4774
|
id: "review-pack-file-missing",
|
|
3930
4775
|
category: "Review Contract",
|
|
3931
4776
|
severity: packHydrationSeverity,
|
|
3932
|
-
message: packHydrationOptional ? "The compiled review pack file is not hydrated yet; contract-only Brownfield projects can continue without it." : "The compiled review pack file is missing.",
|
|
3933
|
-
evidence: [
|
|
4777
|
+
message: packHydrationOptional ? "The compiled review pack file is not hydrated yet; contract-only/style-bridge Brownfield projects can continue without it." : "The compiled review pack file is missing.",
|
|
4778
|
+
evidence: [join3(projectRoot, ".decantr", "context", "review-pack.json")],
|
|
3934
4779
|
suggestedFix: packHydrationOptional ? "Optional: hydrate hosted packs later if you want critique consumers to anchor findings to the compiled review contract." : "Hydrate the full hosted context bundle with `decantr registry compile-packs decantr.essence.json --write-context` so critique consumers can anchor findings to the compiled review contract."
|
|
3935
4780
|
})
|
|
3936
4781
|
);
|
|
@@ -4071,7 +4916,7 @@ function countKeyboardShortcutSignals(code) {
|
|
|
4071
4916
|
return matches.length;
|
|
4072
4917
|
}
|
|
4073
4918
|
function getScriptKind(filePath) {
|
|
4074
|
-
switch (
|
|
4919
|
+
switch (extname3(filePath).toLowerCase()) {
|
|
4075
4920
|
case ".tsx":
|
|
4076
4921
|
return ts.ScriptKind.TSX;
|
|
4077
4922
|
case ".jsx":
|
|
@@ -10358,16 +11203,16 @@ function critiqueSource({
|
|
|
10358
11203
|
const findings = [];
|
|
10359
11204
|
const scores = [];
|
|
10360
11205
|
const antiPatternIds = new Set(reviewPack?.antiPatterns.map((entry) => entry.id) ?? []);
|
|
10361
|
-
const
|
|
11206
|
+
const isProjectOwnedStyling = adoptionMode === "contract-only" || adoptionMode === "style-bridge";
|
|
10362
11207
|
const usedTreatments = TREATMENT_CLASSES.filter((token) => code.includes(token));
|
|
10363
|
-
if (
|
|
11208
|
+
if (isProjectOwnedStyling) {
|
|
10364
11209
|
scores.push({
|
|
10365
11210
|
category: "Styling Authority",
|
|
10366
11211
|
focusArea: "treatment-usage",
|
|
10367
11212
|
score: 3,
|
|
10368
|
-
details: "Contract-only adoption does not require Decantr d-* treatment classes in source files.",
|
|
11213
|
+
details: "Contract-only/style-bridge adoption does not require Decantr d-* treatment classes in source files.",
|
|
10369
11214
|
suggestions: [
|
|
10370
|
-
"Codify project-owned component variants
|
|
11215
|
+
"Codify project-owned component variants, local rules, or a style bridge when button/card/surface drift needs stronger enforcement."
|
|
10371
11216
|
]
|
|
10372
11217
|
});
|
|
10373
11218
|
} else {
|
|
@@ -10404,12 +11249,12 @@ function critiqueSource({
|
|
|
10404
11249
|
const decoratorNames = buildDecoratorInventory(treatmentsCss);
|
|
10405
11250
|
const usedDecorators = decoratorNames.filter((name) => code.includes(name));
|
|
10406
11251
|
const usesCssVars = code.includes("var(--");
|
|
10407
|
-
if (
|
|
11252
|
+
if (isProjectOwnedStyling) {
|
|
10408
11253
|
scores.push({
|
|
10409
11254
|
category: "Theme Consistency",
|
|
10410
11255
|
focusArea: "theme-consistency",
|
|
10411
11256
|
score: usesCssVars ? 4 : 3,
|
|
10412
|
-
details: `Contract-only mode: Decantr decorators are not required; CSS vars: ${usesCssVars ? "yes" : "no"}.`,
|
|
11257
|
+
details: `Contract-only/style-bridge mode: Decantr decorators are not required; CSS vars: ${usesCssVars ? "yes" : "no"}.`,
|
|
10413
11258
|
suggestions: [
|
|
10414
11259
|
"Use the app design system, Tailwind theme, Sass variables, component variants, or accepted local rules as the styling authority."
|
|
10415
11260
|
]
|
|
@@ -11436,7 +12281,7 @@ function critiqueSource({
|
|
|
11436
12281
|
`Disallowed inline style attributes: ${astSignals.inlineStyleAttributeCount}`
|
|
11437
12282
|
],
|
|
11438
12283
|
file: filePath,
|
|
11439
|
-
suggestedFix:
|
|
12284
|
+
suggestedFix: isProjectOwnedStyling ? "Replace static inline visual values with the project design system, accepted component variants, or local style rules. Keep inline style only for truly dynamic geometry." : "Replace static inline visual values with treatments, decorators, and CSS variables from the compiled contract. Keep inline style only for Decantr CSS-variable writes and truly dynamic geometry."
|
|
11440
12285
|
})
|
|
11441
12286
|
);
|
|
11442
12287
|
}
|
|
@@ -11453,7 +12298,7 @@ function critiqueSource({
|
|
|
11453
12298
|
message: "Component-scoped style tags or dynamic style elements were detected in the reviewed file.",
|
|
11454
12299
|
evidence: [filePath, `Component style tag signals: ${componentStyleTagSignals}`],
|
|
11455
12300
|
file: filePath,
|
|
11456
|
-
suggestedFix:
|
|
12301
|
+
suggestedFix: isProjectOwnedStyling ? "Move shared keyframes, media queries, and visual rules into the project stylesheet, component library, or accepted local pattern/rule manifest." : "Move shared keyframes, media queries, and visual rules into global.css or treatments.css so the file stays aligned with the Decantr layer contract."
|
|
11457
12302
|
})
|
|
11458
12303
|
);
|
|
11459
12304
|
}
|
|
@@ -12091,11 +12936,11 @@ function critiqueSource({
|
|
|
12091
12936
|
};
|
|
12092
12937
|
}
|
|
12093
12938
|
function resolveProjectFilePath(projectRoot, filePath) {
|
|
12094
|
-
const root =
|
|
12095
|
-
const candidatePath =
|
|
12096
|
-
const resolvedPath =
|
|
12097
|
-
const relativePath =
|
|
12098
|
-
if (relativePath.startsWith("..") ||
|
|
12939
|
+
const root = existsSync3(projectRoot) ? realpathSync.native(projectRoot) : resolve(projectRoot);
|
|
12940
|
+
const candidatePath = isAbsolute2(filePath) ? resolve(filePath) : resolve(root, filePath);
|
|
12941
|
+
const resolvedPath = existsSync3(candidatePath) ? realpathSync.native(candidatePath) : candidatePath;
|
|
12942
|
+
const relativePath = relative2(root, resolvedPath);
|
|
12943
|
+
if (relativePath.startsWith("..") || isAbsolute2(relativePath)) {
|
|
12099
12944
|
throw new Error(`Path escapes the project root: ${filePath}`);
|
|
12100
12945
|
}
|
|
12101
12946
|
return resolvedPath;
|
|
@@ -12103,7 +12948,7 @@ function resolveProjectFilePath(projectRoot, filePath) {
|
|
|
12103
12948
|
async function critiqueFile(filePath, projectRoot) {
|
|
12104
12949
|
const resolvedPath = resolveProjectFilePath(projectRoot, filePath);
|
|
12105
12950
|
const code = await readFile(resolvedPath, "utf-8");
|
|
12106
|
-
const treatmentsCss = readTextIfExists(
|
|
12951
|
+
const treatmentsCss = readTextIfExists(join3(projectRoot, "src", "styles", "treatments.css"));
|
|
12107
12952
|
const reviewPack = loadReviewPack(projectRoot);
|
|
12108
12953
|
const packManifest = loadPackManifest(projectRoot);
|
|
12109
12954
|
const adoptionMode = readProjectAdoptionMode(projectRoot);
|
|
@@ -12119,17 +12964,22 @@ async function critiqueFile(filePath, projectRoot) {
|
|
|
12119
12964
|
export {
|
|
12120
12965
|
EVIDENCE_BUNDLE_SCHEMA_URL,
|
|
12121
12966
|
INTERACTION_SIGNALS,
|
|
12967
|
+
SCAN_REPORT_SCHEMA_URL,
|
|
12122
12968
|
VERIFICATION_SCHEMA_URLS,
|
|
12123
12969
|
auditBuiltDist,
|
|
12124
12970
|
auditProject,
|
|
12125
12971
|
collectMissingPackManifestFiles,
|
|
12126
12972
|
createContractAssertions,
|
|
12127
12973
|
createEvidenceBundle,
|
|
12974
|
+
createUnavailableScanReport,
|
|
12128
12975
|
critiqueFile,
|
|
12129
12976
|
critiqueSource,
|
|
12130
12977
|
emptyRuntimeAudit,
|
|
12131
12978
|
extractRouteHintsFromEssence,
|
|
12132
12979
|
listKnownInteractions,
|
|
12980
|
+
probePublishedSite,
|
|
12981
|
+
resolveGitHubScanInput,
|
|
12982
|
+
scanProject,
|
|
12133
12983
|
verifyInteractionsInSource
|
|
12134
12984
|
};
|
|
12135
12985
|
//# sourceMappingURL=index.js.map
|