@decantr/verifier 3.5.5 → 3.6.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 +3 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +133 -7
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -35,9 +35,10 @@ npm install @decantr/verifier
|
|
|
35
35
|
- project audits check that `pack-manifest.json` references real pack markdown/JSON files on disk
|
|
36
36
|
- project audits tolerate partial or malformed generated review packs without crashing Project Health, so half-attached Brownfield projects still receive actionable findings
|
|
37
37
|
- Next.js static/document outputs are treated as framework-rendered documents instead of requiring a Vite-style `id="root"` mount element
|
|
38
|
+
- generic static apps can satisfy runtime root proof through semantic app roots such as `main`, `role="main"`, `section.todoapp`, or `#todoapp`, while framework targets still require framework mount/document evidence
|
|
38
39
|
- project source audits ignore test, spec, story, fixture, and mock files for production drift warnings such as localhost endpoints and unsafe rendering patterns
|
|
39
40
|
- project audits emit `COMP001` / `import-existing-component` findings when a production source file locally redeclares a primitive such as `Button`, `Card`, or `Dialog` while an exported reusable primitive already exists under common component paths
|
|
40
|
-
- project audits emit `COMP010` / `replace-raw-control-with-local-component` findings when production JSX renders raw controls such as `<button>` or `<input>` while a project-owned primitive already exists
|
|
41
|
+
- project audits emit `COMP010` / `replace-raw-control-with-local-component` findings when production JSX renders generic raw controls such as `<button>` or text-like `<input>` while a project-owned primitive already exists; specialized inputs such as file, hidden, checkbox, radio, color, range, and Dropzone `getInputProps()` controls are not treated as generic `Input` drift
|
|
41
42
|
- project audits emit behavior-obligation findings when accepted `.decantr/local-patterns.json` patterns declare `behavior_obligations` and production source strongly violates statically checkable dialog/form obligations:
|
|
42
43
|
- `A11Y010` / `restore-dialog-accessible-name`
|
|
43
44
|
- `A11Y011` / `restore-label-association`
|
|
@@ -49,7 +50,7 @@ npm install @decantr/verifier
|
|
|
49
50
|
- project audits emit `TOKEN010` / `replace-arbitrary-style-with-bridge-token` findings when an accepted `.decantr/style-bridge.json` exists and production JSX uses arbitrary Tailwind values such as `bg-[#0f172a]`, values inside `cn()`, `clsx()`, `classnames()`, `cva()`, and `tv()` calls, hardcoded inline color styles such as `style={{ backgroundColor: "#0f172a" }}`, or hardcoded visual values in production CSS/module stylesheets
|
|
50
51
|
- published verifier report schemas are exercised by AJV-backed round-trip tests against real audit, critique, and shortlist-report outputs
|
|
51
52
|
- project audits include runtime evidence when a built `dist/` output is present:
|
|
52
|
-
- root document
|
|
53
|
+
- root document, including semantic static app roots for generic static apps
|
|
53
54
|
- document title
|
|
54
55
|
- document `lang` and `viewport` metadata
|
|
55
56
|
- emitted assets
|
package/dist/index.d.ts
CHANGED
|
@@ -450,6 +450,7 @@ interface RuntimeAudit {
|
|
|
450
450
|
interface BuiltDistAuditOptions {
|
|
451
451
|
distDir?: string;
|
|
452
452
|
routeHints?: string[];
|
|
453
|
+
runtimeTarget?: string | null;
|
|
453
454
|
}
|
|
454
455
|
declare function emptyRuntimeAudit(failures?: string[]): RuntimeAudit;
|
|
455
456
|
declare function auditBuiltDist(projectRoot: string, options?: BuiltDistAuditOptions): Promise<RuntimeAudit>;
|
package/dist/index.js
CHANGED
|
@@ -69,6 +69,18 @@ var RAW_CONTROL_COMPONENT_BY_ELEMENT = {
|
|
|
69
69
|
table: "Table",
|
|
70
70
|
textarea: "Textarea"
|
|
71
71
|
};
|
|
72
|
+
var NON_GENERIC_INPUT_TYPES = /* @__PURE__ */ new Set([
|
|
73
|
+
"button",
|
|
74
|
+
"checkbox",
|
|
75
|
+
"color",
|
|
76
|
+
"file",
|
|
77
|
+
"hidden",
|
|
78
|
+
"image",
|
|
79
|
+
"radio",
|
|
80
|
+
"range",
|
|
81
|
+
"reset",
|
|
82
|
+
"submit"
|
|
83
|
+
]);
|
|
72
84
|
function normalizePath(path) {
|
|
73
85
|
return path.replace(/\\/g, "/");
|
|
74
86
|
}
|
|
@@ -120,10 +132,14 @@ function jsxAttributeStringValue(node, attributeName) {
|
|
|
120
132
|
}
|
|
121
133
|
return null;
|
|
122
134
|
}
|
|
123
|
-
function
|
|
135
|
+
function isSpecializedInput(node) {
|
|
124
136
|
const tagName = jsxIdentifierName(node.tagName);
|
|
125
137
|
if (tagName !== "input") return false;
|
|
126
|
-
|
|
138
|
+
const type = jsxAttributeStringValue(node, "type")?.toLowerCase();
|
|
139
|
+
if (type && NON_GENERIC_INPUT_TYPES.has(type)) return true;
|
|
140
|
+
return node.attributes.properties.some(
|
|
141
|
+
(property) => ts.isJsxSpreadAttribute(property) && /\bgetInputProps\s*\(/.test(property.expression.getText())
|
|
142
|
+
);
|
|
127
143
|
}
|
|
128
144
|
function isReusableComponentPath(file, name) {
|
|
129
145
|
const normalized = normalizePath(file);
|
|
@@ -222,7 +238,7 @@ function collectRawControls(sourceFile, file) {
|
|
|
222
238
|
if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) {
|
|
223
239
|
const tagName = jsxIdentifierName(node.tagName);
|
|
224
240
|
const component = tagName && Object.hasOwn(RAW_CONTROL_COMPONENT_BY_ELEMENT, tagName) ? RAW_CONTROL_COMPONENT_BY_ELEMENT[tagName] : null;
|
|
225
|
-
if (component && !
|
|
241
|
+
if (component && !isSpecializedInput(node)) {
|
|
226
242
|
rawControls.push({
|
|
227
243
|
element: tagName,
|
|
228
244
|
component,
|
|
@@ -992,6 +1008,49 @@ function isNextProject(projectRoot) {
|
|
|
992
1008
|
return false;
|
|
993
1009
|
}
|
|
994
1010
|
}
|
|
1011
|
+
function readProjectDependencies(projectRoot) {
|
|
1012
|
+
try {
|
|
1013
|
+
const pkg = JSON.parse(readFileSync2(join(projectRoot, "package.json"), "utf-8"));
|
|
1014
|
+
return { ...pkg.dependencies ?? {}, ...pkg.devDependencies ?? {} };
|
|
1015
|
+
} catch {
|
|
1016
|
+
return {};
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
function isFrameworkRuntimeProject(projectRoot, runtimeTarget) {
|
|
1020
|
+
const normalizedTarget = runtimeTarget?.toLowerCase() ?? "";
|
|
1021
|
+
if (["angular", "next", "nextjs", "nuxt", "react", "solid", "svelte", "sveltekit", "vue"].includes(
|
|
1022
|
+
normalizedTarget
|
|
1023
|
+
)) {
|
|
1024
|
+
return true;
|
|
1025
|
+
}
|
|
1026
|
+
if (existsSync(join(projectRoot, "vite.config.js")) || existsSync(join(projectRoot, "vite.config.ts")) || existsSync(join(projectRoot, "angular.json")) || existsSync(join(projectRoot, "svelte.config.js")) || existsSync(join(projectRoot, "svelte.config.ts"))) {
|
|
1027
|
+
return true;
|
|
1028
|
+
}
|
|
1029
|
+
const dependencies = readProjectDependencies(projectRoot);
|
|
1030
|
+
return [
|
|
1031
|
+
"@angular/core",
|
|
1032
|
+
"@angular/router",
|
|
1033
|
+
"@sveltejs/kit",
|
|
1034
|
+
"next",
|
|
1035
|
+
"nuxt",
|
|
1036
|
+
"react",
|
|
1037
|
+
"react-dom",
|
|
1038
|
+
"solid-js",
|
|
1039
|
+
"svelte",
|
|
1040
|
+
"vue"
|
|
1041
|
+
].some((name) => Boolean(dependencies[name]));
|
|
1042
|
+
}
|
|
1043
|
+
function hasFrameworkMountPoint(html) {
|
|
1044
|
+
return /\bid=(["'])(?:root|app|__next)\1/i.test(html) || /<app-root(?:\s|>)/i.test(html) || /<[^>]+\bdata-(?:reactroot|sveltekit|astro-cid)\b/i.test(html);
|
|
1045
|
+
}
|
|
1046
|
+
function hasSemanticStaticAppRoot(html) {
|
|
1047
|
+
return /<main(?:\s|>)/i.test(html) || /<[^>]+\brole=(["'])main\1/i.test(html) || /<section\b[^>]*(?:\bid=(["'])todoapp\1|\bclass=(["'])[^"']*\btodoapp\b[^"']*\2)/i.test(html);
|
|
1048
|
+
}
|
|
1049
|
+
function rootDocumentLooksUsable(html, options) {
|
|
1050
|
+
if (options.frameworkDocumentOutput) return true;
|
|
1051
|
+
if (hasFrameworkMountPoint(html)) return true;
|
|
1052
|
+
return !options.frameworkRuntimeProject && hasSemanticStaticAppRoot(html);
|
|
1053
|
+
}
|
|
995
1054
|
async function startStaticServer(rootDir) {
|
|
996
1055
|
const server = createServer((req, res) => {
|
|
997
1056
|
const requestedUrl = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
@@ -1054,6 +1113,7 @@ async function auditBuiltDist(projectRoot, options = {}) {
|
|
|
1054
1113
|
}
|
|
1055
1114
|
const routeHints = Array.isArray(options.routeHints) && options.routeHints.length > 0 ? options.routeHints.map((route) => normalizeRouteHint(route)).filter(Boolean).slice(0, 8) : ["/"];
|
|
1056
1115
|
const frameworkDocumentOutput = isNextProject(projectRoot);
|
|
1116
|
+
const frameworkRuntimeProject = isFrameworkRuntimeProject(projectRoot, options.runtimeTarget);
|
|
1057
1117
|
const indexHtml = readFileSync2(indexPath, "utf-8");
|
|
1058
1118
|
const assetPaths = extractAssetPaths(indexHtml);
|
|
1059
1119
|
const server = await startStaticServer(distDir);
|
|
@@ -1061,7 +1121,7 @@ async function auditBuiltDist(projectRoot, options = {}) {
|
|
|
1061
1121
|
const rootResponse = await fetch(`${server.baseUrl}/`);
|
|
1062
1122
|
const rootHtml = await rootResponse.text();
|
|
1063
1123
|
const failures = frameworkDocumentOutput ? ["next-build-output"] : [];
|
|
1064
|
-
const rootDocumentOk = rootResponse.ok && (frameworkDocumentOutput
|
|
1124
|
+
const rootDocumentOk = rootResponse.ok && rootDocumentLooksUsable(rootHtml, { frameworkDocumentOutput, frameworkRuntimeProject });
|
|
1065
1125
|
const titleOk = /<title>[^<]+<\/title>/i.test(rootHtml);
|
|
1066
1126
|
const langOk = /<html[^>]*\slang=(["'])[^"']+\1/i.test(rootHtml);
|
|
1067
1127
|
const viewportOk = /<meta[^>]+name=(["'])viewport\1[^>]*>/i.test(rootHtml);
|
|
@@ -1141,7 +1201,7 @@ async function auditBuiltDist(projectRoot, options = {}) {
|
|
|
1141
1201
|
for (const routeHint of routeHints) {
|
|
1142
1202
|
const routeResponse = await fetch(`${server.baseUrl}${routeHint}`);
|
|
1143
1203
|
const routeHtml = await routeResponse.text();
|
|
1144
|
-
const routeRootDocumentOk = routeResponse.ok && (frameworkDocumentOutput
|
|
1204
|
+
const routeRootDocumentOk = routeResponse.ok && rootDocumentLooksUsable(routeHtml, { frameworkDocumentOutput, frameworkRuntimeProject });
|
|
1145
1205
|
const routeTitleOk = /<title>[^<]+<\/title>/i.test(routeHtml);
|
|
1146
1206
|
const routeLangOk = /<html[^>]*\slang=(["'])[^"']+\1/i.test(routeHtml);
|
|
1147
1207
|
const routeViewportOk = /<meta[^>]+name=(["'])viewport\1[^>]*>/i.test(routeHtml);
|
|
@@ -2631,6 +2691,42 @@ function walkNextAppRoutes(dir, projectRoot, segments) {
|
|
|
2631
2691
|
}
|
|
2632
2692
|
return routes;
|
|
2633
2693
|
}
|
|
2694
|
+
function walkSvelteKitRoutes(dir, projectRoot, segments) {
|
|
2695
|
+
let entries;
|
|
2696
|
+
try {
|
|
2697
|
+
entries = readdirSync3(dir);
|
|
2698
|
+
} catch {
|
|
2699
|
+
return [];
|
|
2700
|
+
}
|
|
2701
|
+
const routes = [];
|
|
2702
|
+
const pageFile = entries.find((entry) => /^\+page\.(svelte|ts|js)$/.test(entry));
|
|
2703
|
+
const hasLayout = entries.some((entry) => /^\+layout\.(svelte|ts|js)$/.test(entry));
|
|
2704
|
+
if (pageFile) {
|
|
2705
|
+
routes.push({
|
|
2706
|
+
path: `/${segments.filter(Boolean).join("/")}` || "/",
|
|
2707
|
+
file: relative3(projectRoot, join3(dir, pageFile)).replace(/\\/g, "/"),
|
|
2708
|
+
hasLayout
|
|
2709
|
+
});
|
|
2710
|
+
}
|
|
2711
|
+
for (const entry of entries) {
|
|
2712
|
+
if (shouldSkipDir(entry)) continue;
|
|
2713
|
+
const fullPath = join3(dir, entry);
|
|
2714
|
+
try {
|
|
2715
|
+
if (!statSync2(fullPath).isDirectory()) continue;
|
|
2716
|
+
} catch {
|
|
2717
|
+
continue;
|
|
2718
|
+
}
|
|
2719
|
+
const routeSegment = segmentToRoute(entry);
|
|
2720
|
+
routes.push(
|
|
2721
|
+
...walkSvelteKitRoutes(
|
|
2722
|
+
fullPath,
|
|
2723
|
+
projectRoot,
|
|
2724
|
+
routeSegment === null ? segments : [...segments, routeSegment]
|
|
2725
|
+
)
|
|
2726
|
+
);
|
|
2727
|
+
}
|
|
2728
|
+
return routes;
|
|
2729
|
+
}
|
|
2634
2730
|
function fileRouteFromPath(file, baseDir) {
|
|
2635
2731
|
let withoutExt = file.slice(0, -extname3(file).length);
|
|
2636
2732
|
if (withoutExt.endsWith("/index")) withoutExt = withoutExt.slice(0, -"/index".length);
|
|
@@ -2681,6 +2777,31 @@ function scanReactRouter(projectRoot) {
|
|
|
2681
2777
|
}
|
|
2682
2778
|
return { routes: [...routes.values()], hashRouting };
|
|
2683
2779
|
}
|
|
2780
|
+
function normalizeRouterObjectPath(path) {
|
|
2781
|
+
const cleaned = path.trim();
|
|
2782
|
+
if (!cleaned || cleaned === "/") return "/";
|
|
2783
|
+
if (cleaned === "*" || cleaned === "**" || cleaned.startsWith("#")) return null;
|
|
2784
|
+
if (ROUTE_ASSET_EXTENSION_RE.test(cleaned)) return null;
|
|
2785
|
+
return cleaned.startsWith("/") ? cleaned.replace(/\/+$/g, "") || "/" : `/${cleaned}`;
|
|
2786
|
+
}
|
|
2787
|
+
function scanAngularRouter(projectRoot) {
|
|
2788
|
+
const routes = /* @__PURE__ */ new Map();
|
|
2789
|
+
const files = walkFiles(projectRoot, {
|
|
2790
|
+
extensions: /* @__PURE__ */ new Set([".ts", ".tsx", ".js", ".jsx"])
|
|
2791
|
+
});
|
|
2792
|
+
for (const file of files) {
|
|
2793
|
+
const content = readTextFile(join3(projectRoot, file));
|
|
2794
|
+
if (!content) continue;
|
|
2795
|
+
const isRouterFile = content.includes("@angular/router") || content.includes("RouterModule.forRoot") || content.includes("provideRouter") || /\bRoutes\s*=/.test(content);
|
|
2796
|
+
if (!isRouterFile) continue;
|
|
2797
|
+
for (const match of content.matchAll(/\bpath\s*:\s*["'`]([^"'`]*)["'`]/g)) {
|
|
2798
|
+
const routePath = normalizeRouterObjectPath(match[1] ?? "");
|
|
2799
|
+
if (!routePath || routes.has(routePath)) continue;
|
|
2800
|
+
routes.set(routePath, { path: routePath, file, hasLayout: false });
|
|
2801
|
+
}
|
|
2802
|
+
}
|
|
2803
|
+
return [...routes.values()];
|
|
2804
|
+
}
|
|
2684
2805
|
function firstRouteLiteralMatch(match) {
|
|
2685
2806
|
return match.slice(1).find((value) => typeof value === "string") ?? null;
|
|
2686
2807
|
}
|
|
@@ -2815,7 +2936,7 @@ function scanRoutes(projectRoot, detection) {
|
|
|
2815
2936
|
if (pagesRoutes.length > 0) return { strategy: "pages-router", routes: pagesRoutes };
|
|
2816
2937
|
}
|
|
2817
2938
|
if (detection.framework === "svelte") {
|
|
2818
|
-
const routes =
|
|
2939
|
+
const routes = existsSync3(join3(projectRoot, "src/routes")) ? walkSvelteKitRoutes(join3(projectRoot, "src/routes"), projectRoot, []) : [];
|
|
2819
2940
|
if (routes.length > 0) return { strategy: "sveltekit-router", routes };
|
|
2820
2941
|
}
|
|
2821
2942
|
if (detection.framework === "nuxt") {
|
|
@@ -2828,6 +2949,10 @@ function scanRoutes(projectRoot, detection) {
|
|
|
2828
2949
|
const router = scanReactRouter(projectRoot);
|
|
2829
2950
|
if (router.routes.length > 0) return { strategy: "vue-router", routes: router.routes };
|
|
2830
2951
|
}
|
|
2952
|
+
if (detection.framework === "angular") {
|
|
2953
|
+
const routes = scanAngularRouter(projectRoot);
|
|
2954
|
+
if (routes.length > 0) return { strategy: "angular-router", routes };
|
|
2955
|
+
}
|
|
2831
2956
|
const reactRouter = scanReactRouter(projectRoot);
|
|
2832
2957
|
if (reactRouter.routes.length > 0)
|
|
2833
2958
|
return { strategy: "react-router", routes: reactRouter.routes };
|
|
@@ -5634,7 +5759,8 @@ function appendTopologyFindings(findings, essence, reviewPack) {
|
|
|
5634
5759
|
}
|
|
5635
5760
|
async function runRuntimeAudit(projectRoot, essence) {
|
|
5636
5761
|
return auditBuiltDist(projectRoot, {
|
|
5637
|
-
routeHints: extractRouteHintsFromEssence(essence)
|
|
5762
|
+
routeHints: extractRouteHintsFromEssence(essence),
|
|
5763
|
+
runtimeTarget: typeof essence?.meta?.target === "string" ? essence.meta.target : null
|
|
5638
5764
|
});
|
|
5639
5765
|
}
|
|
5640
5766
|
function extractFailedRouteDocuments(runtimeAudit) {
|