@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 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 isHiddenInput(node) {
135
+ function isSpecializedInput(node) {
124
136
  const tagName = jsxIdentifierName(node.tagName);
125
137
  if (tagName !== "input") return false;
126
- return jsxAttributeStringValue(node, "type")?.toLowerCase() === "hidden";
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 && !isHiddenInput(node)) {
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 || /id="root"/.test(rootHtml));
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 || /id="root"/.test(routeHtml));
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 = scanFileRoutes(projectRoot, "src/routes", /* @__PURE__ */ new Set([".svelte", ".ts", ".js"]));
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) {