@decantr/verifier 3.0.0-next.0 → 3.0.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/dist/index.js CHANGED
@@ -1064,10 +1064,9 @@ function isClassHelperCall(node) {
1064
1064
  return Boolean(helperName && CLASS_HELPER_NAMES.has(helperName));
1065
1065
  }
1066
1066
  function templateExpressionText(expression) {
1067
- return [
1068
- expression.head.text,
1069
- ...expression.templateSpans.map((span) => span.literal.text)
1070
- ].join(" ");
1067
+ return [expression.head.text, ...expression.templateSpans.map((span) => span.literal.text)].join(
1068
+ " "
1069
+ );
1071
1070
  }
1072
1071
  function collectClassTextsFromNode(node) {
1073
1072
  const entries = [];
@@ -1297,9 +1296,7 @@ function auditStyleBridgeDrift(projectRoot, sourceFiles) {
1297
1296
  );
1298
1297
  return {
1299
1298
  filesChecked: filesToCheck.filter(
1300
- (file) => !isNonProductionStyleBridgeDriftFile(
1301
- normalizePath2(relative2(projectRoot, file) || file)
1302
- )
1299
+ (file) => !isNonProductionStyleBridgeDriftFile(normalizePath2(relative2(projectRoot, file) || file))
1303
1300
  ).length,
1304
1301
  bridgeMappingCount: bridge.mappingIds.length,
1305
1302
  arbitraryValueCount: findings.length,
@@ -1346,6 +1343,18 @@ var KNOWN_VERIFICATION_DIAGNOSTICS = [
1346
1343
  repairId: "replace-raw-control-with-local-component",
1347
1344
  family: "COMP"
1348
1345
  },
1346
+ {
1347
+ rule: "behavior-project-interaction-primitive",
1348
+ code: "COMP020",
1349
+ repairId: "use-project-owned-interaction-primitive",
1350
+ family: "COMP"
1351
+ },
1352
+ {
1353
+ rule: "behavior-dialog-project-primitive",
1354
+ code: "COMP020",
1355
+ repairId: "use-project-owned-interaction-primitive",
1356
+ family: "COMP"
1357
+ },
1349
1358
  {
1350
1359
  rule: "component-reuse-primitive-reimplemented",
1351
1360
  code: "COMP001",
@@ -1388,6 +1397,42 @@ var KNOWN_VERIFICATION_DIAGNOSTICS = [
1388
1397
  repairId: "enable-focus-visible",
1389
1398
  family: "A11Y"
1390
1399
  },
1400
+ {
1401
+ rule: "behavior-dialog-accessible-name",
1402
+ code: "A11Y010",
1403
+ repairId: "restore-dialog-accessible-name",
1404
+ family: "A11Y"
1405
+ },
1406
+ {
1407
+ rule: "behavior-form-label-associated",
1408
+ code: "A11Y011",
1409
+ repairId: "restore-label-association",
1410
+ family: "A11Y"
1411
+ },
1412
+ {
1413
+ rule: "behavior-dialog-visible-consequence",
1414
+ code: "INT010",
1415
+ repairId: "restore-visible-consequence-copy",
1416
+ family: "INT"
1417
+ },
1418
+ {
1419
+ rule: "behavior-dialog-cancel-affordance",
1420
+ code: "INT011",
1421
+ repairId: "restore-cancel-affordance",
1422
+ family: "INT"
1423
+ },
1424
+ {
1425
+ rule: "behavior-dialog-submitting-guard",
1426
+ code: "INT012",
1427
+ repairId: "restore-submitting-guard",
1428
+ family: "INT"
1429
+ },
1430
+ {
1431
+ rule: "behavior-form-explicit-button-type",
1432
+ code: "INT013",
1433
+ repairId: "set-explicit-button-type",
1434
+ family: "INT"
1435
+ },
1391
1436
  {
1392
1437
  rule: "pack-manifest-present",
1393
1438
  code: "CTX002",
@@ -1953,7 +1998,16 @@ import { existsSync as existsSync3, readdirSync as readdirSync3, readFileSync as
1953
1998
  import { extname as extname3, isAbsolute, join as join3, relative as relative3 } from "path";
1954
1999
  import { decodeHTML, decodeHTMLAttribute } from "entities";
1955
2000
  var SCAN_REPORT_SCHEMA_URL = "https://decantr.ai/schemas/scan-report.v1.json";
1956
- var SOURCE_EXTENSIONS = /* @__PURE__ */ new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".vue", ".svelte"]);
2001
+ var SOURCE_EXTENSIONS = /* @__PURE__ */ new Set([
2002
+ ".ts",
2003
+ ".tsx",
2004
+ ".js",
2005
+ ".jsx",
2006
+ ".mjs",
2007
+ ".cjs",
2008
+ ".vue",
2009
+ ".svelte"
2010
+ ]);
1957
2011
  var STYLE_EXTENSIONS = /* @__PURE__ */ new Set([".css", ".scss", ".sass", ".less"]);
1958
2012
  var PAGE_EXTENSIONS = /* @__PURE__ */ new Set([".ts", ".tsx", ".js", ".jsx", ".vue", ".svelte", ".html"]);
1959
2013
  var MAX_FILE_READ_BYTES = 512 * 1024;
@@ -2034,7 +2088,11 @@ function readPackageJson(projectRoot) {
2034
2088
  if (!content) return { value: null, present: true, valid: false };
2035
2089
  try {
2036
2090
  const parsed = JSON.parse(content);
2037
- return { value: isRecord(parsed) ? parsed : null, present: true, valid: isRecord(parsed) };
2091
+ return {
2092
+ value: isRecord(parsed) ? parsed : null,
2093
+ present: true,
2094
+ valid: isRecord(parsed)
2095
+ };
2038
2096
  } catch {
2039
2097
  return { value: null, present: true, valid: false };
2040
2098
  }
@@ -2062,7 +2120,8 @@ function detectPackageManager(projectRoot, pkg) {
2062
2120
  }
2063
2121
  function detectPrimaryLanguage(projectRoot, packageJsonPresent) {
2064
2122
  if (packageJsonPresent) return "javascript";
2065
- if (hasAnyFile(projectRoot, ["pyproject.toml", "requirements.txt", "setup.py", "Pipfile"])) return "python";
2123
+ if (hasAnyFile(projectRoot, ["pyproject.toml", "requirements.txt", "setup.py", "Pipfile"]))
2124
+ return "python";
2066
2125
  if (hasAnyFile(projectRoot, ["go.mod"])) return "go";
2067
2126
  if (hasAnyFile(projectRoot, ["Cargo.toml"])) return "rust";
2068
2127
  if (hasAnyFile(projectRoot, ["index.html", "docs/index.html"])) return "html";
@@ -2194,14 +2253,23 @@ function walkNextAppRoutes(dir, projectRoot, segments) {
2194
2253
  continue;
2195
2254
  }
2196
2255
  const routeSegment = segmentToRoute(entry);
2197
- routes.push(...walkNextAppRoutes(fullPath, projectRoot, routeSegment === null ? segments : [...segments, routeSegment]));
2256
+ routes.push(
2257
+ ...walkNextAppRoutes(
2258
+ fullPath,
2259
+ projectRoot,
2260
+ routeSegment === null ? segments : [...segments, routeSegment]
2261
+ )
2262
+ );
2198
2263
  }
2199
2264
  return routes;
2200
2265
  }
2201
2266
  function fileRouteFromPath(file, baseDir) {
2202
2267
  let withoutExt = file.slice(0, -extname3(file).length);
2203
2268
  if (withoutExt.endsWith("/index")) withoutExt = withoutExt.slice(0, -"/index".length);
2204
- withoutExt = withoutExt.replace(new RegExp(`^${baseDir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`), "");
2269
+ withoutExt = withoutExt.replace(
2270
+ new RegExp(`^${baseDir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`),
2271
+ ""
2272
+ );
2205
2273
  const parts = withoutExt.split("/").filter(Boolean).map((part) => segmentToRoute(part)).filter(Boolean);
2206
2274
  return `/${parts.join("/")}` || "/";
2207
2275
  }
@@ -2241,7 +2309,8 @@ function scanRoutes(projectRoot, detection) {
2241
2309
  );
2242
2310
  const pagesRoutes = ["src/pages", "pages"].flatMap((dir) => scanFileRoutes(projectRoot, dir));
2243
2311
  if (detection.framework === "nextjs") {
2244
- if (appRoutes.length > 0 && pagesRoutes.length > 0) return { strategy: "mixed-next-router", routes: [...appRoutes, ...pagesRoutes] };
2312
+ if (appRoutes.length > 0 && pagesRoutes.length > 0)
2313
+ return { strategy: "mixed-next-router", routes: [...appRoutes, ...pagesRoutes] };
2245
2314
  if (appRoutes.length > 0) return { strategy: "app-router", routes: appRoutes };
2246
2315
  if (pagesRoutes.length > 0) return { strategy: "pages-router", routes: pagesRoutes };
2247
2316
  }
@@ -2250,7 +2319,9 @@ function scanRoutes(projectRoot, detection) {
2250
2319
  if (routes.length > 0) return { strategy: "sveltekit-router", routes };
2251
2320
  }
2252
2321
  if (detection.framework === "nuxt") {
2253
- const routes = ["pages", "app/pages"].flatMap((dir) => scanFileRoutes(projectRoot, dir, /* @__PURE__ */ new Set([".vue"])));
2322
+ const routes = ["pages", "app/pages"].flatMap(
2323
+ (dir) => scanFileRoutes(projectRoot, dir, /* @__PURE__ */ new Set([".vue"]))
2324
+ );
2254
2325
  if (routes.length > 0) return { strategy: "nuxt-router", routes };
2255
2326
  }
2256
2327
  if (detection.framework === "vue") {
@@ -2258,13 +2329,20 @@ function scanRoutes(projectRoot, detection) {
2258
2329
  if (router.routes.length > 0) return { strategy: "vue-router", routes: router.routes };
2259
2330
  }
2260
2331
  const reactRouter = scanReactRouter(projectRoot);
2261
- if (reactRouter.routes.length > 0) return { strategy: "react-router", routes: reactRouter.routes };
2332
+ if (reactRouter.routes.length > 0)
2333
+ return { strategy: "react-router", routes: reactRouter.routes };
2262
2334
  if (pagesRoutes.length > 0) return { strategy: "pages-router", routes: pagesRoutes };
2263
2335
  if (existsSync3(join3(projectRoot, "index.html"))) {
2264
- return { strategy: "static-html", routes: [{ path: "/", file: "index.html", hasLayout: false }] };
2336
+ return {
2337
+ strategy: "static-html",
2338
+ routes: [{ path: "/", file: "index.html", hasLayout: false }]
2339
+ };
2265
2340
  }
2266
2341
  if (existsSync3(join3(projectRoot, "docs", "index.html"))) {
2267
- return { strategy: "static-html", routes: [{ path: "/", file: "docs/index.html", hasLayout: false }] };
2342
+ return {
2343
+ strategy: "static-html",
2344
+ routes: [{ path: "/", file: "docs/index.html", hasLayout: false }]
2345
+ };
2268
2346
  }
2269
2347
  return { strategy: "none", routes: [] };
2270
2348
  }
@@ -2274,7 +2352,11 @@ function countComponents(projectRoot, routes) {
2274
2352
  const base = file.split("/").pop() ?? file;
2275
2353
  return /^[A-Z][\w-]*\.(tsx|ts|jsx|js|vue|svelte)$/.test(base);
2276
2354
  });
2277
- const directories = [...new Set(componentFiles.map((file) => file.split("/").slice(0, -1).join("/")).filter(Boolean))].slice(0, 16);
2355
+ const directories = [
2356
+ ...new Set(
2357
+ componentFiles.map((file) => file.split("/").slice(0, -1).join("/")).filter(Boolean)
2358
+ )
2359
+ ].slice(0, 16);
2278
2360
  return {
2279
2361
  pageCount: routes.routes.length,
2280
2362
  componentCount: componentFiles.length,
@@ -2285,7 +2367,8 @@ function extractCssEvidence(content) {
2285
2367
  const variableMatches = [...content.matchAll(/--[\w-]+\s*:/g)];
2286
2368
  const colorMatches = [...content.matchAll(/#[0-9a-fA-F]{3,8}\b|rgb[a]?\(|hsl[a]?\(/g)];
2287
2369
  const themeSignals = /* @__PURE__ */ new Set();
2288
- if (/\bdark\b|color-scheme:\s*dark|\[data-theme=['"]dark['"]|\.dark\b/.test(content)) themeSignals.add("dark mode");
2370
+ if (/\bdark\b|color-scheme:\s*dark|\[data-theme=['"]dark['"]|\.dark\b/.test(content))
2371
+ themeSignals.add("dark mode");
2289
2372
  if (/\[data-theme=|data-theme|theme-\w+/.test(content)) themeSignals.add("theme selector");
2290
2373
  if (/prefers-color-scheme/.test(content)) themeSignals.add("system color preference");
2291
2374
  return {
@@ -2304,9 +2387,12 @@ function scanStyling(projectRoot, detection) {
2304
2387
  let darkMode = false;
2305
2388
  let configFile = null;
2306
2389
  let approach = "unknown";
2307
- const tailwindConfig = ["tailwind.config.js", "tailwind.config.ts", "tailwind.config.mjs", "tailwind.config.cjs"].find(
2308
- (file) => existsSync3(join3(projectRoot, file))
2309
- );
2390
+ const tailwindConfig = [
2391
+ "tailwind.config.js",
2392
+ "tailwind.config.ts",
2393
+ "tailwind.config.mjs",
2394
+ "tailwind.config.cjs"
2395
+ ].find((file) => existsSync3(join3(projectRoot, file)));
2310
2396
  if (tailwindConfig || deps.tailwindcss) {
2311
2397
  approach = "tailwind";
2312
2398
  configFile = tailwindConfig ?? "package.json";
@@ -2363,13 +2449,17 @@ function scanStaticHosting(projectRoot, detection) {
2363
2449
  if (detection.dependencies["gh-pages"] || pkg?.scripts?.deploy?.includes("gh-pages")) {
2364
2450
  evidence.push("package scripts or dependencies reference gh-pages");
2365
2451
  }
2366
- if (existsSync3(join3(projectRoot, "docs", "index.html"))) evidence.push("docs/index.html can serve GitHub Pages");
2452
+ if (existsSync3(join3(projectRoot, "docs", "index.html")))
2453
+ evidence.push("docs/index.html can serve GitHub Pages");
2367
2454
  if (existsSync3(join3(projectRoot, "404.html")) || existsSync3(join3(projectRoot, "docs", "404.html"))) {
2368
2455
  evidence.push("404.html fallback is present");
2369
2456
  }
2370
2457
  const workflowDir = join3(projectRoot, ".github", "workflows");
2371
2458
  if (existsSync3(workflowDir)) {
2372
- for (const file of walkFiles(workflowDir, { extensions: /* @__PURE__ */ new Set([".yml", ".yaml"]) }).slice(0, 20)) {
2459
+ for (const file of walkFiles(workflowDir, { extensions: /* @__PURE__ */ new Set([".yml", ".yaml"]) }).slice(
2460
+ 0,
2461
+ 20
2462
+ )) {
2373
2463
  const content = readTextFile(join3(workflowDir, file));
2374
2464
  if (content && /pages|gh-pages|upload-pages-artifact|deploy-pages/i.test(content)) {
2375
2465
  evidence.push(`GitHub Pages workflow hint: .github/workflows/${file}`);
@@ -2397,7 +2487,9 @@ function scanStaticHosting(projectRoot, detection) {
2397
2487
  }
2398
2488
  }
2399
2489
  return {
2400
- githubPagesLikely: evidence.some((item) => item.toLowerCase().includes("github") || item.toLowerCase().includes("pages")),
2490
+ githubPagesLikely: evidence.some(
2491
+ (item) => item.toLowerCase().includes("github") || item.toLowerCase().includes("pages")
2492
+ ),
2401
2493
  evidence: [...new Set(evidence)].slice(0, 12),
2402
2494
  homepageUrl,
2403
2495
  basePath,
@@ -2419,7 +2511,10 @@ function buildApplicability(detection, routes, components) {
2419
2511
  return {
2420
2512
  status: "strong_fit",
2421
2513
  label: "Good Brownfield scan target",
2422
- reasons: ["A supported web UI framework or static site entrypoint was detected.", "Routes or UI component files are present."]
2514
+ reasons: [
2515
+ "A supported web UI framework or static site entrypoint was detected.",
2516
+ "Routes or UI component files are present."
2517
+ ]
2423
2518
  };
2424
2519
  }
2425
2520
  return {
@@ -2481,7 +2576,10 @@ function buildFindings(input) {
2481
2576
  severity: "info",
2482
2577
  title: "Not a Brownfield UI target",
2483
2578
  message: "This repository does not look like a supported frontend application for Decantr adoption.",
2484
- evidence: [`primary language: ${detection.primaryLanguage}`, `framework: ${detection.framework}`],
2579
+ evidence: [
2580
+ `primary language: ${detection.primaryLanguage}`,
2581
+ `framework: ${detection.framework}`
2582
+ ],
2485
2583
  recommendation: "Use Decantr scan on a frontend app or GitHub Pages site."
2486
2584
  });
2487
2585
  return findings;
@@ -2518,7 +2616,9 @@ function buildFindings(input) {
2518
2616
  severity: "success",
2519
2617
  title: "Style authority detected",
2520
2618
  message: `The project appears to use ${styling.approach}. Decantr should preserve that authority during Brownfield adoption.`,
2521
- evidence: [styling.configFile ? `${styling.approach}: ${styling.configFile}` : `styling approach: ${styling.approach}`]
2619
+ evidence: [
2620
+ styling.configFile ? `${styling.approach}: ${styling.configFile}` : `styling approach: ${styling.approach}`
2621
+ ]
2522
2622
  });
2523
2623
  }
2524
2624
  if (hosting.githubPagesLikely) {
@@ -2614,7 +2714,15 @@ async function scanProject(projectRoot, options = {}) {
2614
2714
  ruleFiles: assistantRules
2615
2715
  },
2616
2716
  pagesProbe,
2617
- findings: buildFindings({ detection, routes, styling, hosting: staticHosting, assistantRules, applicability, pagesProbe }),
2717
+ findings: buildFindings({
2718
+ detection,
2719
+ routes,
2720
+ styling,
2721
+ hosting: staticHosting,
2722
+ assistantRules,
2723
+ applicability,
2724
+ pagesProbe
2725
+ }),
2618
2726
  recommendedCommands: buildCommands(applicability),
2619
2727
  privacy: {
2620
2728
  sourceUploaded: input.kind !== "local",
@@ -2947,6 +3055,9 @@ function evidenceRepairReadTargets(projectRoot, finding) {
2947
3055
  if (finding.source === "style-bridge") {
2948
3056
  targets.add(".decantr/style-bridge.json");
2949
3057
  }
3058
+ if (finding.category.toLowerCase().includes("behavior obligation") || finding.rule?.startsWith("behavior:")) {
3059
+ targets.add(".decantr/local-patterns.json");
3060
+ }
2950
3061
  if (finding.source === "pack" || finding.source === "assertion") {
2951
3062
  targets.add(".decantr/context/pack-manifest.json");
2952
3063
  }
@@ -4824,6 +4935,322 @@ function appendRuntimeAuditFindings(findings, runtimeAudit, projectRoot, topolog
4824
4935
  );
4825
4936
  }
4826
4937
  }
4938
+ function readBehaviorPatterns(projectRoot) {
4939
+ const pack = readJsonIfExists(
4940
+ join4(projectRoot, ".decantr", "local-patterns.json")
4941
+ );
4942
+ return (pack?.patterns ?? []).filter(
4943
+ (pattern) => pattern.behavior_obligations && Array.isArray(pattern.behavior_obligations.obligations) && pattern.behavior_obligations.obligations.length > 0
4944
+ );
4945
+ }
4946
+ function behaviorPatternRole(pattern) {
4947
+ return (pattern.behavior_obligations?.pattern_role ?? pattern.role ?? pattern.id ?? "behavior-obligation").trim().toLowerCase();
4948
+ }
4949
+ function behaviorSlug(value) {
4950
+ return value.trim().toLowerCase().replace(/[^a-z0-9_.:/-]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
4951
+ }
4952
+ function behaviorRuleId(patternId, obligationId) {
4953
+ return `behavior:${behaviorSlug(patternId) || "pattern"}:${behaviorSlug(obligationId) || "obligation"}`;
4954
+ }
4955
+ function behaviorPatternHasObligation(pattern, obligationId) {
4956
+ return Boolean(
4957
+ pattern.behavior_obligations?.obligations?.some((obligation) => obligation.id === obligationId)
4958
+ );
4959
+ }
4960
+ function behaviorRepairPayload(input) {
4961
+ return {
4962
+ local_pattern_id: input.patternId,
4963
+ behavior_obligation_id: input.obligationId,
4964
+ file: input.file,
4965
+ ...input.componentPaths?.length ? { component_paths: input.componentPaths } : {}
4966
+ };
4967
+ }
4968
+ function sourceAuditFilePath(projectRoot, file) {
4969
+ return isAbsolute2(file) ? file : join4(projectRoot, file);
4970
+ }
4971
+ function sourceAuditRelativePath(projectRoot, file) {
4972
+ return (isAbsolute2(file) ? relative4(projectRoot, file) : file).replace(/\\/g, "/");
4973
+ }
4974
+ function sourceFileContainsAny(projectRoot, sourceFiles, patterns) {
4975
+ for (const file of sourceFiles.slice(0, 400)) {
4976
+ try {
4977
+ const code = readFileSync5(sourceAuditFilePath(projectRoot, file), "utf-8");
4978
+ if (patterns.some((pattern) => pattern.test(code))) return true;
4979
+ } catch {
4980
+ }
4981
+ }
4982
+ return false;
4983
+ }
4984
+ function hasProjectDialogPrimitive(pattern) {
4985
+ return (pattern.componentPaths ?? []).some((file) => /dialog|alert|modal|confirm/i.test(file));
4986
+ }
4987
+ function fileUsesProjectDialogPrimitive(code) {
4988
+ return /<\s*(?:AlertDialog|Dialog|Modal|ConfirmationDialog|ConfirmDialog)\b/.test(code);
4989
+ }
4990
+ function fileHasDialogSurface(code) {
4991
+ return /<\s*dialog\b/i.test(code) || /role\s*=\s*["'](?:dialog|alertdialog)["']/i.test(code) || /<\s*(?:AlertDialog|Dialog|Modal|ConfirmationDialog|ConfirmDialog)\b/.test(code) || /\b(?:fixed|absolute)\b[^"'`]+(?:inset-0|z-\d+|bg-black\/|backdrop)/i.test(code);
4992
+ }
4993
+ function fileHasDestructiveIntent(code) {
4994
+ return /\b(?:delete|remove|destroy|destructive|permanent|irreversible|cannot be undone|account deletion)\b/i.test(
4995
+ code
4996
+ );
4997
+ }
4998
+ function fileHasDialogAccessibleName(code) {
4999
+ return /(?:aria-label|aria-labelledby|title)\s*=/i.test(code) || /<\s*(?:AlertDialogTitle|DialogTitle|ModalTitle|h[1-6])\b/i.test(code);
5000
+ }
5001
+ function fileHasVisibleConsequenceCopy(code) {
5002
+ return /\b(?:cannot be undone|permanent(?:ly)?|irreversible|lose access|delete your account|remove this|destroy)\b/i.test(
5003
+ code
5004
+ );
5005
+ }
5006
+ function fileHasCancelAffordance(code) {
5007
+ return /\b(?:cancel|close|dismiss|go back|keep account|never mind)\b/i.test(code) || /<\s*(?:AlertDialogCancel|DialogClose|ModalClose)\b/i.test(code);
5008
+ }
5009
+ function fileHasSubmittingGuard(code) {
5010
+ return /\b(?:isSubmitting|submitting|isPending|pending|loading|disabled|aria-disabled|useTransition)\b/i.test(
5011
+ code
5012
+ );
5013
+ }
5014
+ function appendBehaviorObligationFindings(findings, projectRoot, sourceFiles) {
5015
+ const behaviorPatterns = readBehaviorPatterns(projectRoot);
5016
+ if (behaviorPatterns.length === 0) return;
5017
+ const formPattern = behaviorPatterns.find(
5018
+ (pattern) => /form-control|form|input|field/.test(`${pattern.id ?? ""} ${behaviorPatternRole(pattern)}`)
5019
+ );
5020
+ const dialogPattern = behaviorPatterns.find(
5021
+ (pattern) => /confirmation-dialog|dialog|modal|alertdialog|destructive/.test(
5022
+ `${pattern.id ?? ""} ${behaviorPatternRole(pattern)}`
5023
+ )
5024
+ );
5025
+ if (formPattern) {
5026
+ const patternId = formPattern.id ?? "form-control";
5027
+ for (const file of sourceFiles.slice(0, 400)) {
5028
+ const relativeFile = sourceAuditRelativePath(projectRoot, file);
5029
+ let code = "";
5030
+ try {
5031
+ code = readFileSync5(sourceAuditFilePath(projectRoot, file), "utf-8");
5032
+ } catch {
5033
+ continue;
5034
+ }
5035
+ if (!/<\s*(?:form|input|select|textarea|button)\b/i.test(code)) continue;
5036
+ const signals = analyzeAstSignals(relativeFile, code);
5037
+ if (behaviorPatternHasObligation(formPattern, "label-associated") && signals.formControlWithoutLabelCount > 0) {
5038
+ findings.push(
5039
+ makeFinding({
5040
+ id: "behavior-form-label-associated",
5041
+ code: "A11Y011",
5042
+ category: "Behavior Obligation",
5043
+ severity: "warn",
5044
+ message: "Accepted form-control behavior obligations require controls to keep associated labels.",
5045
+ evidence: [
5046
+ ".decantr/local-patterns.json behavior_obligations: form-control/label-associated",
5047
+ `${relativeFile}: form controls without labels: ${signals.formControlWithoutLabelCount}`
5048
+ ],
5049
+ file: relativeFile,
5050
+ target: patternId,
5051
+ rule: behaviorRuleId(patternId, "label-associated"),
5052
+ suggestedFix: "Associate each input, select, or textarea with a visible label, htmlFor/id pair, aria-label, or aria-labelledby while preserving the project-owned form primitive.",
5053
+ repair: {
5054
+ id: "restore-label-association",
5055
+ payload: behaviorRepairPayload({
5056
+ patternId,
5057
+ obligationId: "label-associated",
5058
+ file: relativeFile,
5059
+ componentPaths: formPattern.componentPaths
5060
+ })
5061
+ }
5062
+ })
5063
+ );
5064
+ }
5065
+ if (behaviorPatternHasObligation(formPattern, "explicit-form-button-type") && signals.buttonInFormWithoutTypeCount > 0) {
5066
+ findings.push(
5067
+ makeFinding({
5068
+ id: "behavior-form-explicit-button-type",
5069
+ code: "INT013",
5070
+ category: "Behavior Obligation",
5071
+ severity: "warn",
5072
+ message: "Accepted form-control behavior obligations require buttons inside forms to declare an explicit type.",
5073
+ evidence: [
5074
+ ".decantr/local-patterns.json behavior_obligations: form-control/explicit-form-button-type",
5075
+ `${relativeFile}: buttons inside forms without type: ${signals.buttonInFormWithoutTypeCount}`
5076
+ ],
5077
+ file: relativeFile,
5078
+ target: patternId,
5079
+ rule: behaviorRuleId(patternId, "explicit-form-button-type"),
5080
+ suggestedFix: 'Set type="button" for non-submit actions and type="submit" for the intended submit control.',
5081
+ repair: {
5082
+ id: "set-explicit-button-type",
5083
+ payload: behaviorRepairPayload({
5084
+ patternId,
5085
+ obligationId: "explicit-form-button-type",
5086
+ file: relativeFile,
5087
+ componentPaths: formPattern.componentPaths
5088
+ })
5089
+ }
5090
+ })
5091
+ );
5092
+ }
5093
+ }
5094
+ }
5095
+ if (!dialogPattern) return;
5096
+ const dialogPatternId = dialogPattern.id ?? "confirmation-dialog";
5097
+ const projectHasDialogPrimitive = hasProjectDialogPrimitive(dialogPattern);
5098
+ const sourceHasDialogIntent = sourceFileContainsAny(projectRoot, sourceFiles, [
5099
+ /<\s*(?:dialog|AlertDialog|Dialog|Modal|ConfirmationDialog|ConfirmDialog)\b/i,
5100
+ /role\s*=\s*["'](?:dialog|alertdialog)["']/i
5101
+ ]);
5102
+ if (!sourceHasDialogIntent) return;
5103
+ for (const file of sourceFiles.slice(0, 400)) {
5104
+ const relativeFile = sourceAuditRelativePath(projectRoot, file);
5105
+ let code = "";
5106
+ try {
5107
+ code = readFileSync5(sourceAuditFilePath(projectRoot, file), "utf-8");
5108
+ } catch {
5109
+ continue;
5110
+ }
5111
+ if (!fileHasDialogSurface(code) || !fileHasDestructiveIntent(code)) continue;
5112
+ if (behaviorPatternHasObligation(dialogPattern, "project-dialog-primitive") && projectHasDialogPrimitive && !fileUsesProjectDialogPrimitive(code)) {
5113
+ findings.push(
5114
+ makeFinding({
5115
+ id: "behavior-dialog-project-primitive",
5116
+ code: "COMP020",
5117
+ category: "Behavior Obligation",
5118
+ severity: "warn",
5119
+ message: "Accepted confirmation-dialog behavior obligations require the project-owned Dialog primitive instead of one-off destructive overlays.",
5120
+ evidence: [
5121
+ ".decantr/local-patterns.json behavior_obligations: confirmation-dialog/project-dialog-primitive",
5122
+ `${relativeFile}: destructive dialog-like surface does not use the project Dialog primitive`
5123
+ ],
5124
+ file: relativeFile,
5125
+ target: dialogPatternId,
5126
+ rule: behaviorRuleId(dialogPatternId, "project-dialog-primitive"),
5127
+ suggestedFix: "Replace the one-off overlay with the project-owned Dialog or AlertDialog primitive listed in .decantr/local-patterns.json.",
5128
+ repair: {
5129
+ id: "use-project-owned-interaction-primitive",
5130
+ payload: behaviorRepairPayload({
5131
+ patternId: dialogPatternId,
5132
+ obligationId: "project-dialog-primitive",
5133
+ file: relativeFile,
5134
+ componentPaths: dialogPattern.componentPaths
5135
+ })
5136
+ }
5137
+ })
5138
+ );
5139
+ }
5140
+ if (behaviorPatternHasObligation(dialogPattern, "accessible-name") && !fileHasDialogAccessibleName(code)) {
5141
+ findings.push(
5142
+ makeFinding({
5143
+ id: "behavior-dialog-accessible-name",
5144
+ code: "A11Y010",
5145
+ category: "Behavior Obligation",
5146
+ severity: "error",
5147
+ message: "Accepted confirmation-dialog behavior obligations require the destructive dialog to have an accessible name.",
5148
+ evidence: [
5149
+ ".decantr/local-patterns.json behavior_obligations: confirmation-dialog/accessible-name",
5150
+ `${relativeFile}: destructive dialog-like surface has no DialogTitle, aria-label, aria-labelledby, title, or heading signal`
5151
+ ],
5152
+ file: relativeFile,
5153
+ target: dialogPatternId,
5154
+ rule: behaviorRuleId(dialogPatternId, "accessible-name"),
5155
+ suggestedFix: "Add the project Dialog title primitive or an aria-label/aria-labelledby that names the destructive confirmation.",
5156
+ repair: {
5157
+ id: "restore-dialog-accessible-name",
5158
+ payload: behaviorRepairPayload({
5159
+ patternId: dialogPatternId,
5160
+ obligationId: "accessible-name",
5161
+ file: relativeFile,
5162
+ componentPaths: dialogPattern.componentPaths
5163
+ })
5164
+ }
5165
+ })
5166
+ );
5167
+ }
5168
+ if (behaviorPatternHasObligation(dialogPattern, "visible-consequence") && !fileHasVisibleConsequenceCopy(code)) {
5169
+ findings.push(
5170
+ makeFinding({
5171
+ id: "behavior-dialog-visible-consequence",
5172
+ code: "INT010",
5173
+ category: "Behavior Obligation",
5174
+ severity: "warn",
5175
+ message: "Accepted confirmation-dialog behavior obligations require visible copy that states the destructive consequence.",
5176
+ evidence: [
5177
+ ".decantr/local-patterns.json behavior_obligations: confirmation-dialog/visible-consequence",
5178
+ `${relativeFile}: destructive dialog-like surface lacks consequence copy such as "cannot be undone" or "permanently"`
5179
+ ],
5180
+ file: relativeFile,
5181
+ target: dialogPatternId,
5182
+ rule: behaviorRuleId(dialogPatternId, "visible-consequence"),
5183
+ suggestedFix: "Add visible consequence copy that clearly explains what will be deleted, removed, or lost.",
5184
+ repair: {
5185
+ id: "restore-visible-consequence-copy",
5186
+ payload: behaviorRepairPayload({
5187
+ patternId: dialogPatternId,
5188
+ obligationId: "visible-consequence",
5189
+ file: relativeFile,
5190
+ componentPaths: dialogPattern.componentPaths
5191
+ })
5192
+ }
5193
+ })
5194
+ );
5195
+ }
5196
+ if (behaviorPatternHasObligation(dialogPattern, "cancel-affordance") && !fileHasCancelAffordance(code)) {
5197
+ findings.push(
5198
+ makeFinding({
5199
+ id: "behavior-dialog-cancel-affordance",
5200
+ code: "INT011",
5201
+ category: "Behavior Obligation",
5202
+ severity: "warn",
5203
+ message: "Accepted confirmation-dialog behavior obligations require a visible cancel or close affordance.",
5204
+ evidence: [
5205
+ ".decantr/local-patterns.json behavior_obligations: confirmation-dialog/cancel-affordance",
5206
+ `${relativeFile}: destructive dialog-like surface lacks a cancel/close affordance signal`
5207
+ ],
5208
+ file: relativeFile,
5209
+ target: dialogPatternId,
5210
+ rule: behaviorRuleId(dialogPatternId, "cancel-affordance"),
5211
+ suggestedFix: "Add a reviewed cancel/close affordance before the destructive action using the project Dialog primitive.",
5212
+ repair: {
5213
+ id: "restore-cancel-affordance",
5214
+ payload: behaviorRepairPayload({
5215
+ patternId: dialogPatternId,
5216
+ obligationId: "cancel-affordance",
5217
+ file: relativeFile,
5218
+ componentPaths: dialogPattern.componentPaths
5219
+ })
5220
+ }
5221
+ })
5222
+ );
5223
+ }
5224
+ if (behaviorPatternHasObligation(dialogPattern, "submitting-guard") && !fileHasSubmittingGuard(code)) {
5225
+ findings.push(
5226
+ makeFinding({
5227
+ id: "behavior-dialog-submitting-guard",
5228
+ code: "INT012",
5229
+ category: "Behavior Obligation",
5230
+ severity: "info",
5231
+ message: "Accepted confirmation-dialog behavior obligations expect a submitting guard for destructive execution.",
5232
+ evidence: [
5233
+ ".decantr/local-patterns.json behavior_obligations: confirmation-dialog/submitting-guard",
5234
+ `${relativeFile}: destructive dialog-like surface has no obvious disabled/loading/pending guard`
5235
+ ],
5236
+ file: relativeFile,
5237
+ target: dialogPatternId,
5238
+ rule: behaviorRuleId(dialogPatternId, "submitting-guard"),
5239
+ suggestedFix: "Guard the destructive submit path with a disabled, pending, loading, or submitting state to avoid repeated execution.",
5240
+ repair: {
5241
+ id: "restore-submitting-guard",
5242
+ payload: behaviorRepairPayload({
5243
+ patternId: dialogPatternId,
5244
+ obligationId: "submitting-guard",
5245
+ file: relativeFile,
5246
+ componentPaths: dialogPattern.componentPaths
5247
+ })
5248
+ }
5249
+ })
5250
+ );
5251
+ }
5252
+ }
5253
+ }
4827
5254
  function appendSourceAuditFindings(findings, sourceAudit, essence, reviewPack, adoptionMode) {
4828
5255
  if (sourceAudit.filesChecked === 0) {
4829
5256
  return;
@@ -6211,6 +6638,7 @@ async function auditProject(projectRoot) {
6211
6638
  sourceAudit
6212
6639
  );
6213
6640
  appendSourceAuditFindings(findings, sourceAudit, essence, reviewPack, adoptionMode);
6641
+ appendBehaviorObligationFindings(findings, projectRoot, projectSourceFiles);
6214
6642
  appendComponentReuseFindings(findings, componentReuseAudit);
6215
6643
  appendStyleBridgeDriftFindings(findings, styleBridgeDriftAudit);
6216
6644
  appendStyleContractFindings(findings, styleAudit, essence);