@decantr/verifier 3.0.0-next.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -0
- package/dist/index.d.ts +62 -22
- package/dist/index.js +457 -29
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
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
|
-
|
|
1069
|
-
|
|
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([
|
|
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 {
|
|
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"]))
|
|
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(
|
|
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(
|
|
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)
|
|
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(
|
|
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)
|
|
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 {
|
|
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 {
|
|
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 = [
|
|
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))
|
|
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 = [
|
|
2308
|
-
|
|
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")))
|
|
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(
|
|
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(
|
|
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: [
|
|
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: [
|
|
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: [
|
|
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({
|
|
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);
|