shadscan-vue 0.1.2 → 0.2.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
@@ -4,7 +4,7 @@ import path from "node:path";
4
4
  import { cac } from "cac";
5
5
  import { glob } from "tinyglobby";
6
6
  import { parse } from "@vue/compiler-sfc";
7
- import { NodeTypes } from "@vue/compiler-dom";
7
+ import { ElementTypes, NodeTypes } from "@vue/compiler-dom";
8
8
  import ts from "typescript";
9
9
  import { parse as parse$1 } from "jsonc-parser";
10
10
  import pc from "picocolors";
@@ -736,6 +736,19 @@ const collectText = (children) => {
736
736
  };
737
737
  const visibleText = (element) => collectText(element.children).trim();
738
738
  /**
739
+ * True when the element projects caller-supplied content through a `<slot>`.
740
+ * The name then lives at the call site, so the component itself cannot be
741
+ * judged nameless — `<a><slot /></a>` is a wrapper, not an anonymous link.
742
+ */
743
+ const projectsSlotContent = (element) => {
744
+ for (const child of element.children) {
745
+ if (child.type !== NodeTypes.ELEMENT) continue;
746
+ if (child.tagType === ElementTypes.SLOT) return true;
747
+ if (projectsSlotContent(child)) return true;
748
+ }
749
+ return false;
750
+ };
751
+ /**
739
752
  * True when the element carries an explicit accessible name via ARIA,
740
753
  * a title, or visually-hidden text.
741
754
  */
@@ -771,6 +784,145 @@ const hasScreenReaderText = (element) => {
771
784
  };
772
785
  const elementChildren = (element) => element.children.filter((child) => child.type === NodeTypes.ELEMENT);
773
786
  //#endregion
787
+ //#region src/rules/accessibility/custom-controls-have-labels.ts
788
+ /**
789
+ * Controls that render no intrinsic text, so a screen reader has nothing to
790
+ * announce unless the author supplies a name. SelectTrigger is included rather
791
+ * than Select because the trigger is the focusable control.
792
+ */
793
+ const CUSTOM_CONTROL_TAGS = /* @__PURE__ */ new Set([
794
+ "checkbox",
795
+ "combobox",
796
+ "input-otp",
797
+ "radio-group",
798
+ "select-trigger",
799
+ "slider",
800
+ "switch",
801
+ "toggle"
802
+ ]);
803
+ const LABEL_TAGS = /* @__PURE__ */ new Set([
804
+ "label",
805
+ "form-label",
806
+ "field-label"
807
+ ]);
808
+ const isCustomControl = (tag) => CUSTOM_CONTROL_TAGS.has(pascalToKebab(tag));
809
+ const customControlsHaveLabels = {
810
+ id: "custom-controls-have-labels",
811
+ title: "Custom controls have accessible labels",
812
+ description: "Controls such as Checkbox, Switch, Slider, and SelectTrigger render no text of their own, so each needs a label, an aria-label, or an aria-labelledby reference.",
813
+ category: "accessibility",
814
+ severity: "error",
815
+ confidence: "medium",
816
+ maxScore: 4,
817
+ adapters: [
818
+ "nuxt",
819
+ "vite-vue",
820
+ "generic-vue"
821
+ ],
822
+ run: async ({ sources, result }) => {
823
+ const files = await sources();
824
+ const labelTargets = /* @__PURE__ */ new Map();
825
+ forEachElement$1(files, (element, file) => {
826
+ if (!LABEL_TAGS.has(pascalToKebab(element.tag))) return;
827
+ const target = findAttribute(element, "for")?.static;
828
+ if (target === void 0) return;
829
+ const targets = labelTargets.get(file.relPath) ?? /* @__PURE__ */ new Set();
830
+ targets.add(target);
831
+ labelTargets.set(file.relPath, targets);
832
+ });
833
+ const findings = [];
834
+ let evaluated = 0;
835
+ forEachElement$1(files, (element, file, ancestors) => {
836
+ if (!isCustomControl(element.tag) || isGeneratedUiPrimitive(file.relPath)) return;
837
+ evaluated += 1;
838
+ const id = findAttribute(element, "id")?.static;
839
+ const labelledByFor = id !== void 0 && labelTargets.get(file.relPath)?.has(id) === true;
840
+ const wrappedInLabel = ancestors.some((ancestor) => LABEL_TAGS.has(pascalToKebab(ancestor.tag)));
841
+ if (labelledByFor || wrappedInLabel || hasAriaName(element)) return;
842
+ findings.push({
843
+ message: `<${element.tag}> has no accessible name.`,
844
+ evidence: [{
845
+ path: file.relPath,
846
+ line: elementLine(element)
847
+ }],
848
+ remediation: "Associate a <Label for=\"…\"> with the control id, wrap the control in a label, or add aria-label / aria-labelledby."
849
+ });
850
+ });
851
+ if (evaluated === 0) return result.notApplicable();
852
+ return findings.length > 0 ? result.fail(findings) : result.pass();
853
+ }
854
+ };
855
+ //#endregion
856
+ //#region src/rules/accessibility/dialog-focus-trap-works.ts
857
+ const DIALOG_TAGS = /* @__PURE__ */ new Set([
858
+ "dialog",
859
+ "dialog-root",
860
+ "dialog-content",
861
+ "alert-dialog",
862
+ "alert-dialog-root",
863
+ "alert-dialog-content",
864
+ "drawer",
865
+ "drawer-content",
866
+ "sheet",
867
+ "sheet-content"
868
+ ]);
869
+ const DIALOG_ROLES = /* @__PURE__ */ new Set(["dialog", "alertdialog"]);
870
+ /**
871
+ * reka-ui is the headless layer shadcn-vue is built on, so its dialog
872
+ * primitives already own focus containment and restoration. Vaul-vue is the
873
+ * drawer equivalent.
874
+ */
875
+ const FOCUS_MANAGED_DEPENDENCIES = [
876
+ "reka-ui",
877
+ "radix-vue",
878
+ "vaul-vue",
879
+ "@headlessui/vue"
880
+ ];
881
+ const isDialogTag = (tag) => DIALOG_TAGS.has(pascalToKebab(tag));
882
+ const findDialogSurface = (files) => {
883
+ for (const file of files) {
884
+ if (file.sfc?.templateAst === void 0 || isGeneratedUiPrimitive(file.relPath)) continue;
885
+ let found;
886
+ walkTemplate(file.sfc.templateAst, (element) => {
887
+ if (found !== void 0) return;
888
+ const role = findAttribute(element, "role")?.static;
889
+ if (isDialogTag(element.tag) || role !== void 0 && DIALOG_ROLES.has(role)) found = elementLine(element);
890
+ });
891
+ if (found !== void 0) return {
892
+ file,
893
+ line: found
894
+ };
895
+ }
896
+ };
897
+ const dialogFocusTrapWorks = {
898
+ id: "dialog-focus-trap-works",
899
+ title: "Dialogs trap and restore focus",
900
+ description: "Looks for a focus-managed primitive behind dialog-like surfaces. Focus containment and restoration cannot be proven statically, so this reports what backs the dialog rather than scoring it.",
901
+ category: "accessibility",
902
+ severity: "warning",
903
+ confidence: "low",
904
+ maxScore: 0,
905
+ adapters: [
906
+ "nuxt",
907
+ "vite-vue",
908
+ "generic-vue"
909
+ ],
910
+ run: async ({ sources, discovery, result }) => {
911
+ const files = await sources();
912
+ const surface = findDialogSurface(files);
913
+ if (surface === void 0) return result.notApplicable();
914
+ if (FOCUS_MANAGED_DEPENDENCIES.find((dependency) => discovery.dependencies[dependency] !== void 0) !== void 0) return result.pass();
915
+ return result.advisory([{
916
+ message: "A dialog surface was found with no focus-managed primitive behind it.",
917
+ evidence: [{
918
+ path: surface.file.relPath,
919
+ line: surface.line
920
+ }],
921
+ remediation: "Build the dialog on reka-ui (the layer shadcn-vue uses) or another focus-managed primitive, then verify initial focus, Tab containment, Escape, and focus return in a browser."
922
+ }]);
923
+ }
924
+ };
925
+ //#endregion
774
926
  //#region src/rules/accessibility/dialogs-have-accessible-names.ts
775
927
  /**
776
928
  * shadcn-vue dialog surfaces wrap reka-ui primitives. Both the shadcn wrapper
@@ -839,6 +991,130 @@ const dialogsHaveAccessibleNames = {
839
991
  }
840
992
  };
841
993
  //#endregion
994
+ //#region src/rules/accessibility/keyboard-navigation-works.ts
995
+ const COMPOSITE_WIDGET_TAGS = /* @__PURE__ */ new Set([
996
+ "accordion",
997
+ "accordion-root",
998
+ "combobox",
999
+ "combobox-root",
1000
+ "command",
1001
+ "dropdown-menu",
1002
+ "dropdown-menu-root",
1003
+ "listbox",
1004
+ "menu",
1005
+ "menubar",
1006
+ "navigation-menu",
1007
+ "select",
1008
+ "select-root",
1009
+ "tabs",
1010
+ "tabs-root",
1011
+ "tree"
1012
+ ]);
1013
+ const COMPOSITE_ROLES = /* @__PURE__ */ new Set([
1014
+ "grid",
1015
+ "listbox",
1016
+ "menu",
1017
+ "menubar",
1018
+ "tablist",
1019
+ "tree"
1020
+ ]);
1021
+ const KEYBOARD_PRIMITIVE_DEPENDENCIES = [
1022
+ "reka-ui",
1023
+ "radix-vue",
1024
+ "@headlessui/vue",
1025
+ "@ariakit/vue",
1026
+ "@zag-js/vue"
1027
+ ];
1028
+ const isCompositeTag = (tag) => COMPOSITE_WIDGET_TAGS.has(pascalToKebab(tag));
1029
+ const findCompositeWidget = (files) => {
1030
+ for (const file of files) {
1031
+ if (file.sfc?.templateAst === void 0 || isGeneratedUiPrimitive(file.relPath)) continue;
1032
+ let found;
1033
+ walkTemplate(file.sfc.templateAst, (element) => {
1034
+ if (found !== void 0) return;
1035
+ const role = findAttribute(element, "role")?.static;
1036
+ if (isCompositeTag(element.tag) || role !== void 0 && COMPOSITE_ROLES.has(role)) found = elementLine(element);
1037
+ });
1038
+ if (found !== void 0) return {
1039
+ file,
1040
+ line: found
1041
+ };
1042
+ }
1043
+ };
1044
+ const keyboardNavigationWorks = {
1045
+ id: "keyboard-navigation-works",
1046
+ title: "Composite widgets support keyboard navigation",
1047
+ description: "Looks for a keyboard-aware primitive behind composite widgets such as menus, tabs, and listboxes. Arrow-key, Home/End, and focus behaviour cannot be proven statically, so this reports what backs the widget rather than scoring it.",
1048
+ category: "accessibility",
1049
+ severity: "warning",
1050
+ confidence: "low",
1051
+ maxScore: 0,
1052
+ adapters: [
1053
+ "nuxt",
1054
+ "vite-vue",
1055
+ "generic-vue"
1056
+ ],
1057
+ run: async ({ sources, discovery, result }) => {
1058
+ const files = await sources();
1059
+ const widget = findCompositeWidget(files);
1060
+ if (widget === void 0) return result.notApplicable();
1061
+ if (Object.keys(discovery.dependencies).find((dependency) => dependency.startsWith("@zag-js/") || KEYBOARD_PRIMITIVE_DEPENDENCIES.includes(dependency)) !== void 0) return result.pass();
1062
+ return result.advisory([{
1063
+ message: "A composite widget was found with no keyboard-aware primitive behind it.",
1064
+ evidence: [{
1065
+ path: widget.file.relPath,
1066
+ line: widget.line
1067
+ }],
1068
+ remediation: "Build composite widgets on reka-ui (the layer shadcn-vue uses), or implement and test the widget's arrow-key, Home/End, Enter, Escape, and focus behaviour."
1069
+ }]);
1070
+ }
1071
+ };
1072
+ //#endregion
1073
+ //#region src/rules/accessibility/status-messages-announced.ts
1074
+ /**
1075
+ * `error.statusMessage` is Nuxt's static field on a full-page error view, not
1076
+ * a status update pushed into an already-rendered page, so the leading
1077
+ * boundary excludes a property access.
1078
+ */
1079
+ const DYNAMIC_STATUS_PATTERN = /(?<![.\w])(?:statusMessage|successMessage|errorMessage|progressMessage|setStatus|setMessage)\b|(?<![.\w])(?:pending|isPending|isLoading|loading)\s*\?/u;
1080
+ const ANNOUNCEMENT_PATTERN = /role\s*=\s*["'](?:status|alert)["']|aria-live\s*=|<(?:Toaster|ToastProvider|Sonner)\b|\btoast(?:\.|\s*\()/u;
1081
+ const lineOf$5 = (text, pattern) => {
1082
+ const index = text.search(pattern);
1083
+ if (index < 0) return 1;
1084
+ return text.slice(0, index).split("\n").length;
1085
+ };
1086
+ const statusMessagesAnnounced = {
1087
+ id: "status-messages-announced",
1088
+ title: "Status messages are announced",
1089
+ description: "Dynamic status messages should reach assistive technology through a live region or an accessible toast channel, otherwise a sighted user sees the update and a screen reader user does not.",
1090
+ category: "accessibility",
1091
+ severity: "warning",
1092
+ confidence: "medium",
1093
+ maxScore: 3,
1094
+ adapters: [
1095
+ "nuxt",
1096
+ "vite-vue",
1097
+ "generic-vue"
1098
+ ],
1099
+ run: async ({ sources, result }) => {
1100
+ const candidates = (await sources()).filter((file) => file.kind === "vue" && !isGeneratedUiPrimitive(file.relPath) && DYNAMIC_STATUS_PATTERN.test(file.text));
1101
+ if (candidates.length === 0) return result.notApplicable();
1102
+ const findings = [];
1103
+ for (const file of candidates) {
1104
+ if (ANNOUNCEMENT_PATTERN.test(file.text)) continue;
1105
+ findings.push({
1106
+ message: "A dynamic status message has no live region or accessible toast channel.",
1107
+ evidence: [{
1108
+ path: file.relPath,
1109
+ line: lineOf$5(file.text, DYNAMIC_STATUS_PATTERN)
1110
+ }],
1111
+ remediation: "Render the update inside role=\"status\" or role=\"alert\" (or an aria-live region), or deliver it through a mounted toast provider."
1112
+ });
1113
+ }
1114
+ return findings.length > 0 ? result.fail(findings) : result.pass();
1115
+ }
1116
+ };
1117
+ //#endregion
842
1118
  //#region src/rules/accessibility/heading-structure-sane.ts
843
1119
  const HEADING_TAG = /^h([1-6])$/u;
844
1120
  const isRoutableSurface = (relPath) => relPath.startsWith("pages/") || relPath.startsWith("app/pages/") || relPath.startsWith("src/views/") || relPath.startsWith("layouts/") || relPath.startsWith("app/layouts/") || relPath === "app.vue" || relPath === "app/app.vue" || relPath === "App.vue" || relPath === "src/App.vue";
@@ -971,7 +1247,7 @@ const iconButtonsHaveLabels = {
971
1247
  const children = elementChildren(element);
972
1248
  if (!(children.length > 0 && children.every((child) => isIconTag$1(child.tag)) || findAttribute(element, "size")?.static === "icon")) return;
973
1249
  if (visibleText(element).length > 0) return;
974
- if (hasAriaName(element) || hasScreenReaderText(element)) return;
1250
+ if (hasAriaName(element) || hasScreenReaderText(element) || projectsSlotContent(element)) return;
975
1251
  findings.push({
976
1252
  message: `Icon-only <${element.tag}> has no accessible name.`,
977
1253
  evidence: [{
@@ -1176,7 +1452,7 @@ const linksHaveAccessibleNames = {
1176
1452
  if (!LINK_TAGS$2.has(element.tag)) return;
1177
1453
  if (visibleText(element).length > 0) return;
1178
1454
  if (hasAriaName(element) || hasScreenReaderText(element)) return;
1179
- if (hasLabelledImage(element)) return;
1455
+ if (hasLabelledImage(element) || projectsSlotContent(element)) return;
1180
1456
  findings.push({
1181
1457
  message: `<${element.tag}> has no accessible name.`,
1182
1458
  evidence: [{
@@ -1948,7 +2224,7 @@ const FOCUS_REPLACEMENT_CLASS_PATTERN = /\bfocus(?:-visible)?:(?:ring|outline|bo
1948
2224
  const isExcludedPath = (relPath) => /(?:^|\/)components\/ui\//u.test(relPath);
1949
2225
  const CSS_OUTLINE_NONE_PATTERN = /outline\s*:\s*none/giu;
1950
2226
  const FOCUS_VISIBLE_RULE_PATTERN = /:focus(?:-visible)?\b[^{]*\{[^}]*(?:outline|box-shadow|border|ring)[^}]*\}/u;
1951
- const lineOf$1 = (text, index) => text.slice(0, index).split("\n").length;
2227
+ const lineOf$4 = (text, index) => text.slice(0, index).split("\n").length;
1952
2228
  const scanTemplate = (file) => {
1953
2229
  if (file.kind !== "vue" || file.sfc?.templateAst === void 0) return [];
1954
2230
  const findings = [];
@@ -1976,7 +2252,7 @@ const scanStyle = (style) => {
1976
2252
  message: `A CSS rule in ${style.relPath} sets \`outline: none\` without a visible :focus-visible replacement.`,
1977
2253
  evidence: [{
1978
2254
  path: style.relPath,
1979
- line: lineOf$1(style.text, match.index)
2255
+ line: lineOf$4(style.text, match.index)
1980
2256
  }],
1981
2257
  remediation: "Add a `:focus-visible` rule that restores a visible outline, box-shadow, or border."
1982
2258
  });
@@ -2029,7 +2305,7 @@ const TYPING_GUARD_PATTERN$1 = /\b(?:INPUT|TEXTAREA|SELECT)\b|isContentEditable|
2029
2305
  const PRINTABLE_SINGLE_KEY_PATTERN = /\.key\s*===\s*['"][a-z0-9]['"]/iu;
2030
2306
  const MODIFIER_PATTERN = /metaKey|ctrlKey|altKey/u;
2031
2307
  const isSourceFile$1 = (file) => file.kind === "vue" || file.kind === "ts" || file.kind === "js";
2032
- const lineOf = (text, index) => text.slice(0, index).split("\n").length;
2308
+ const lineOf$3 = (text, index) => text.slice(0, index).split("\n").length;
2033
2309
  const globalHotkeysAreSafe = {
2034
2310
  id: "global-hotkeys-are-safe",
2035
2311
  title: "Global keyboard shortcuts are registered safely",
@@ -2055,7 +2331,7 @@ const globalHotkeysAreSafe = {
2055
2331
  message: `A global keydown listener in ${file.relPath} is never removed, leaking across unmounts.`,
2056
2332
  evidence: [{
2057
2333
  path: file.relPath,
2058
- line: lineOf(text, matches[0].index)
2334
+ line: lineOf$3(text, matches[0].index)
2059
2335
  }],
2060
2336
  remediation: "Remove the listener in onUnmounted/onBeforeUnmount, or use VueUse `useEventListener`/`onKeyStroke` which clean up automatically."
2061
2337
  });
@@ -2063,7 +2339,7 @@ const globalHotkeysAreSafe = {
2063
2339
  message: `A bare printable single-key shortcut in ${file.relPath} has no typing-target guard.`,
2064
2340
  evidence: [{
2065
2341
  path: file.relPath,
2066
- line: lineOf(text, matches[0].index)
2342
+ line: lineOf$3(text, matches[0].index)
2067
2343
  }],
2068
2344
  remediation: "Guard the handler so it ignores events whose target is an INPUT, TEXTAREA, SELECT, or contenteditable element, or gate the shortcut behind a modifier key."
2069
2345
  });
@@ -2303,6 +2579,72 @@ const themeHotkeyPresent = {
2303
2579
  }
2304
2580
  };
2305
2581
  //#endregion
2582
+ //#region src/rules/interaction/typing-target-guard.ts
2583
+ const GLOBAL_KEY_BINDING = /addEventListener\s*\(\s*['"]keydown['"]|onKeyStroke\s*\(|useEventListener\s*\([^,]*,\s*['"]keydown['"]/u;
2584
+ const MAGIC_KEYS_SINGLE_LETTER = /\{\s*[^}]*\b(?<!meta_)(?<!ctrl_)(?<!alt_)(?<!shift_)[a-z]\b[^}]*\}\s*=\s*useMagicKeys/u;
2585
+ /**
2586
+ * A shortcut that fires while the user is typing steals the keystroke. The
2587
+ * guard is a check that the event target is not a field: tagName against
2588
+ * INPUT/TEXTAREA/SELECT, an instanceof against the matching element type, a
2589
+ * closest()/matches() selector, or isContentEditable.
2590
+ */
2591
+ const TYPING_GUARD = /\b(?:INPUT|TEXTAREA|SELECT)\b|HTML(?:Input|TextArea|Select)Element|(?:closest|matches)\s*\(\s*['"][^'"]*(?:input|textarea|select)|isContentEditable|contenteditable/iu;
2592
+ const MODIFIER = /metaKey|ctrlKey|altKey|\b(?:meta|ctrl|alt)_/u;
2593
+ /**
2594
+ * Escape, Enter, Tab and the arrows are expected to work while a field has
2595
+ * focus — Escape closing a dialog is correct behaviour, not a stolen
2596
+ * keystroke. A handler that only reacts to those needs no typing guard.
2597
+ */
2598
+ const NAVIGATION_KEYS = /^(?:Escape|Enter|Tab|Arrow[A-Za-z]+|Home|End|Page[A-Za-z]+)$/u;
2599
+ const reactsOnlyToNavigationKeys = (text) => {
2600
+ const compared = [...text.matchAll(/\.key\s*===\s*['"]([^'"]+)['"]/gu)].map((match) => match[1]);
2601
+ return compared.length > 0 && compared.every((key) => NAVIGATION_KEYS.test(key));
2602
+ };
2603
+ const lineOf$2 = (text, pattern) => {
2604
+ const index = text.search(pattern);
2605
+ if (index < 0) return 1;
2606
+ return text.slice(0, index).split("\n").length;
2607
+ };
2608
+ const typingTargetGuard = {
2609
+ id: "typing-target-guard",
2610
+ title: "Global shortcuts ignore keystrokes aimed at a field",
2611
+ description: "A global single-key shortcut should not fire while the user is typing. Handlers bound to the document need a check that the event target is not an input, textarea, select, or contenteditable element.",
2612
+ category: "interaction",
2613
+ severity: "warning",
2614
+ confidence: "medium",
2615
+ maxScore: 3,
2616
+ adapters: [
2617
+ "nuxt",
2618
+ "vite-vue",
2619
+ "generic-vue"
2620
+ ],
2621
+ run: async ({ sources, result }) => {
2622
+ const files = await sources();
2623
+ const findings = [];
2624
+ let evaluated = 0;
2625
+ for (const file of files) {
2626
+ if (file.kind !== "vue" && file.kind !== "ts" && file.kind !== "js") continue;
2627
+ const bindsGlobalKey = GLOBAL_KEY_BINDING.test(file.text);
2628
+ const bindsBareLetter = MAGIC_KEYS_SINGLE_LETTER.test(file.text);
2629
+ if (!bindsGlobalKey && !bindsBareLetter) continue;
2630
+ if (!bindsBareLetter && MODIFIER.test(file.text) && !GLOBAL_KEY_BINDING.test(file.text)) continue;
2631
+ if (reactsOnlyToNavigationKeys(file.text)) continue;
2632
+ evaluated += 1;
2633
+ if (TYPING_GUARD.test(file.text)) continue;
2634
+ findings.push({
2635
+ message: "A global key handler has no guard for keystrokes aimed at a field.",
2636
+ evidence: [{
2637
+ path: file.relPath,
2638
+ line: lineOf$2(file.text, bindsGlobalKey ? GLOBAL_KEY_BINDING : MAGIC_KEYS_SINGLE_LETTER)
2639
+ }],
2640
+ remediation: "Return early when the event target is an input, textarea, select, or contenteditable element before acting on the key."
2641
+ });
2642
+ }
2643
+ if (evaluated === 0) return result.notApplicable();
2644
+ return findings.length > 0 ? result.fail(findings) : result.pass();
2645
+ }
2646
+ };
2647
+ //#endregion
2306
2648
  //#region src/rules/forms/forms-shared.ts
2307
2649
  const VALIDATION_PACKAGES = [
2308
2650
  "vee-validate",
@@ -2559,6 +2901,68 @@ const groupedControlsHaveLegend = {
2559
2901
  }
2560
2902
  };
2561
2903
  //#endregion
2904
+ //#region src/rules/forms/input-group-composition.ts
2905
+ const INPUT_GROUP_TAG = "input-group";
2906
+ const GROUP_PARTS = /* @__PURE__ */ new Set([
2907
+ "input-group-input",
2908
+ "input-group-textarea",
2909
+ "input-group-addon"
2910
+ ]);
2911
+ const RAW_CONTROLS = /* @__PURE__ */ new Set(["input", "textarea"]);
2912
+ const inputGroupComposition = {
2913
+ id: "input-group-composition",
2914
+ title: "InputGroup uses its own parts",
2915
+ description: "An InputGroup wired with a raw input or textarea loses the padding, focus ring, and addon alignment its own parts provide, so the group renders correctly only by accident.",
2916
+ category: "forms",
2917
+ severity: "warning",
2918
+ confidence: "medium",
2919
+ maxScore: 0,
2920
+ adapters: [
2921
+ "nuxt",
2922
+ "vite-vue",
2923
+ "generic-vue"
2924
+ ],
2925
+ run: async ({ sources, result }) => {
2926
+ const files = await sources();
2927
+ const findings = [];
2928
+ let instances = 0;
2929
+ for (const file of files) {
2930
+ if (file.sfc?.templateAst === void 0 || isGeneratedUiPrimitive(file.relPath)) continue;
2931
+ const groups = /* @__PURE__ */ new Map();
2932
+ walkTemplate(file.sfc.templateAst, (element, ancestors) => {
2933
+ const tag = pascalToKebab(element.tag);
2934
+ if (tag === INPUT_GROUP_TAG) {
2935
+ groups.set(element, {
2936
+ line: elementLine(element),
2937
+ usesParts: false
2938
+ });
2939
+ return;
2940
+ }
2941
+ const owner = ancestors.find((ancestor) => pascalToKebab(ancestor.tag) === INPUT_GROUP_TAG);
2942
+ if (owner === void 0) return;
2943
+ const entry = groups.get(owner);
2944
+ if (entry === void 0) return;
2945
+ if (GROUP_PARTS.has(tag)) {
2946
+ entry.usesParts = true;
2947
+ return;
2948
+ }
2949
+ if (RAW_CONTROLS.has(tag)) entry.rawControl ??= tag;
2950
+ });
2951
+ instances += groups.size;
2952
+ for (const entry of groups.values()) if (entry.rawControl !== void 0 && !entry.usesParts) findings.push({
2953
+ message: `<InputGroup> wraps a raw <${entry.rawControl}> instead of its own part.`,
2954
+ evidence: [{
2955
+ path: file.relPath,
2956
+ line: entry.line
2957
+ }],
2958
+ remediation: "Use InputGroupInput or InputGroupTextarea inside InputGroup, with InputGroupAddon for leading and trailing content."
2959
+ });
2960
+ }
2961
+ if (instances === 0) return result.notApplicable();
2962
+ return findings.length > 0 ? result.advisory(findings) : result.pass();
2963
+ }
2964
+ };
2965
+ //#endregion
2562
2966
  //#region src/rules/forms/invalid-fields-associated-with-errors.ts
2563
2967
  const ERROR_COMPONENT = /^(?:form-message|error-message|field-error)$/u;
2564
2968
  const isFormControlWrapper = (tag) => pascalToKebab(tag) === "form-control";
@@ -3114,6 +3518,119 @@ const toastProviderPresent = {
3114
3518
  }
3115
3519
  };
3116
3520
  //#endregion
3521
+ //#region src/rules/states/toast-runtime.ts
3522
+ const TOAST_CALL = /\btoast(?:\.(?:success|error|info|warning|promise|custom|message))?\s*\(/u;
3523
+ /**
3524
+ * The Vue toast runtimes shadcn-vue projects use. vue-sonner is what
3525
+ * shadcn-vue's own Sonner block installs; the others cover the common
3526
+ * alternatives.
3527
+ */
3528
+ const TOAST_DEPENDENCIES = [
3529
+ "vue-sonner",
3530
+ "sonner",
3531
+ "@nuxt/ui",
3532
+ "vue-toastification",
3533
+ "vue3-toastify",
3534
+ "primevue"
3535
+ ];
3536
+ const lineOf$1 = (text, pattern) => {
3537
+ const index = text.search(pattern);
3538
+ if (index < 0) return 1;
3539
+ return text.slice(0, index).split("\n").length;
3540
+ };
3541
+ const toastRuntime = {
3542
+ id: "toast-runtime",
3543
+ title: "Toast calls have a runtime behind them",
3544
+ description: "Code that calls toast() needs a toast library installed, otherwise the call resolves to nothing at runtime and the notification silently never appears.",
3545
+ category: "states",
3546
+ severity: "error",
3547
+ confidence: "high",
3548
+ maxScore: 3,
3549
+ adapters: [
3550
+ "nuxt",
3551
+ "vite-vue",
3552
+ "generic-vue"
3553
+ ],
3554
+ run: async ({ sources, discovery, result }) => {
3555
+ const caller = (await sources()).find((file) => !isGeneratedUiPrimitive(file.relPath) && TOAST_CALL.test(file.text));
3556
+ if (caller === void 0) return result.notApplicable();
3557
+ if (TOAST_DEPENDENCIES.find((dependency) => discovery.dependencies[dependency] !== void 0) !== void 0) return result.pass();
3558
+ return result.fail([{
3559
+ message: "toast() is called but no toast runtime is installed.",
3560
+ evidence: [{
3561
+ path: caller.relPath,
3562
+ line: lineOf$1(caller.text, TOAST_CALL)
3563
+ }],
3564
+ remediation: "Install a toast runtime (shadcn-vue ships the Sonner block backed by vue-sonner) and mount its provider in the app shell."
3565
+ }]);
3566
+ }
3567
+ };
3568
+ //#endregion
3569
+ //#region src/rules/production-polish/alert-anatomy.ts
3570
+ const ALERT_TAG = "alert";
3571
+ const ALERT_TITLE_TAG = "alert-title";
3572
+ const ALERT_DESCRIPTION_TAG = "alert-description";
3573
+ const ICON_TAG$1 = /(?:^|-)icon$/u;
3574
+ const isIcon = (tag) => {
3575
+ const normalized = pascalToKebab(tag);
3576
+ return ICON_TAG$1.test(normalized) || normalized === "svg";
3577
+ };
3578
+ const alertAnatomy = {
3579
+ id: "alert-anatomy",
3580
+ title: "Alert matches its anatomy",
3581
+ description: "An Alert should carry an AlertTitle and at most one icon. A title-less alert announces as an unlabelled region, and a second icon competes with the first for meaning.",
3582
+ category: "production-polish",
3583
+ severity: "warning",
3584
+ confidence: "medium",
3585
+ maxScore: 0,
3586
+ adapters: [
3587
+ "nuxt",
3588
+ "vite-vue",
3589
+ "generic-vue"
3590
+ ],
3591
+ run: async ({ sources, result }) => {
3592
+ const files = await sources();
3593
+ const findings = [];
3594
+ let instances = 0;
3595
+ for (const file of files) {
3596
+ if (file.sfc?.templateAst === void 0 || isGeneratedUiPrimitive(file.relPath)) continue;
3597
+ const alerts = /* @__PURE__ */ new Map();
3598
+ walkTemplate(file.sfc.templateAst, (element, ancestors) => {
3599
+ if (pascalToKebab(element.tag) === ALERT_TAG) {
3600
+ alerts.set(element, {
3601
+ line: elementLine(element),
3602
+ titles: 0,
3603
+ icons: 0
3604
+ });
3605
+ return;
3606
+ }
3607
+ const owner = ancestors.find((ancestor) => pascalToKebab(ancestor.tag) === ALERT_TAG);
3608
+ if (owner === void 0) return;
3609
+ const entry = alerts.get(owner);
3610
+ if (entry === void 0) return;
3611
+ if (pascalToKebab(element.tag) === ALERT_TITLE_TAG) entry.titles += 1;
3612
+ if (isIcon(element.tag)) entry.icons += 1;
3613
+ });
3614
+ instances += alerts.size;
3615
+ for (const entry of alerts.values()) {
3616
+ const problems = [];
3617
+ if (entry.titles === 0) problems.push("no AlertTitle");
3618
+ if (entry.icons > 1) problems.push(`${entry.icons} icons`);
3619
+ if (problems.length > 0) findings.push({
3620
+ message: `<Alert> has ${problems.join(" and ")}.`,
3621
+ evidence: [{
3622
+ path: file.relPath,
3623
+ line: entry.line
3624
+ }],
3625
+ remediation: `Compose Alert from its parts: one AlertTitle, an optional ${ALERT_DESCRIPTION_TAG.replace("-", "")}, and at most one leading icon.`
3626
+ });
3627
+ }
3628
+ }
3629
+ if (instances === 0) return result.notApplicable();
3630
+ return findings.length > 0 ? result.advisory(findings) : result.pass();
3631
+ }
3632
+ };
3633
+ //#endregion
3117
3634
  //#region src/rules/production-polish/polish-shared.ts
3118
3635
  const SHELL_FILES = /* @__PURE__ */ new Set([
3119
3636
  "app.vue",
@@ -3242,10 +3759,67 @@ const buttonIconsHaveDataIcon = {
3242
3759
  }
3243
3760
  };
3244
3761
  //#endregion
3762
+ //#region src/rules/production-polish/color-contrast-passes.ts
3763
+ const COLOR_STYLE_PATTERN = /(?:color|background(?:-color)?|border-color|fill|stroke)\s*:|\b(?:bg|border|fill|stroke|text)-(?:accent|background|card|destructive|foreground|input|muted|popover|primary|ring|secondary|[a-z]+-\d{2,3})\b|#[\da-f]{3,8}\b|(?:hsl|oklch|rgb)a?\s*\(/iu;
3764
+ const lineOf = (text, pattern) => {
3765
+ const index = text.search(pattern);
3766
+ if (index < 0) return 1;
3767
+ return text.slice(0, index).split("\n").length;
3768
+ };
3769
+ const colorContrastPasses = {
3770
+ id: "color-contrast-passes",
3771
+ title: "Rendered colour contrast meets accessibility thresholds",
3772
+ description: "Marks styled colour pairs for browser verification. Computed contrast depends on cascade, theme, and opacity, none of which can be resolved from source, so this reports where to look rather than scoring it.",
3773
+ category: "production-polish",
3774
+ severity: "warning",
3775
+ confidence: "low",
3776
+ maxScore: 0,
3777
+ adapters: [
3778
+ "nuxt",
3779
+ "vite-vue",
3780
+ "generic-vue"
3781
+ ],
3782
+ run: async ({ sources, result }) => {
3783
+ const styled = (await sources()).find((file) => !isGeneratedUiPrimitive(file.relPath) && COLOR_STYLE_PATTERN.test(file.text));
3784
+ if (styled === void 0) return result.notApplicable();
3785
+ return result.advisory([{
3786
+ message: "Colour styling is present, but computed foreground and background contrast cannot be established from source.",
3787
+ evidence: [{
3788
+ path: styled.relPath,
3789
+ line: lineOf(styled.text, COLOR_STYLE_PATTERN)
3790
+ }],
3791
+ remediation: "Check rendered states in every theme and viewport: 4.5:1 for body text, 3:1 for large text, and 3:1 for meaningful UI graphics and boundaries."
3792
+ }]);
3793
+ }
3794
+ };
3795
+ //#endregion
3245
3796
  //#region src/rules/production-polish/metadata-title-description-complete.ts
3246
3797
  const TITLE_PATTERN = /\b(?:title|titleTemplate)\s*:/u;
3247
3798
  const DESCRIPTION_PATTERN = /\bdescription\s*:/u;
3248
3799
  const HEAD_CALL_PATTERN = /\b(?:useSeoMeta|useHead)\s*\(/u;
3800
+ const COMPOSABLE_PATH = /(?:^|\/)composables\//u;
3801
+ const EXPORTED_COMPOSABLE = /export\s+(?:const|function|async\s+function)\s+(use[A-Z]\w*)/gu;
3802
+ /**
3803
+ * A page that redirects during setup never renders, so a crawler follows the
3804
+ * redirect and reads the destination's metadata instead. Demanding a title and
3805
+ * description for it reports a failure with nothing to fix.
3806
+ */
3807
+ const REDIRECT_ONLY = /await\s+navigateTo\s*\(|definePageMeta\s*\(\s*\{[^}]*\bredirect\s*:/su;
3808
+ /**
3809
+ * Extracting the head call into a `usePageSeo`-style composable is the
3810
+ * documented Nuxt pattern, so a page that calls one of those wrappers has
3811
+ * declared its metadata just as surely as one calling useSeoMeta inline.
3812
+ * Collect every project composable that reaches a head call, then treat a call
3813
+ * to it as a metadata declaration.
3814
+ */
3815
+ const seoComposableNames = (files) => {
3816
+ const names = /* @__PURE__ */ new Set();
3817
+ for (const file of files) {
3818
+ if (!COMPOSABLE_PATH.test(file.relPath) || !HEAD_CALL_PATTERN.test(file.text)) continue;
3819
+ for (const match of file.text.matchAll(EXPORTED_COMPOSABLE)) names.add(match[1]);
3820
+ }
3821
+ return names;
3822
+ };
3249
3823
  const metadataTitleDescriptionComplete = {
3250
3824
  id: "metadata-title-description-complete",
3251
3825
  title: "Routable pages declare a title and description",
@@ -3260,11 +3834,15 @@ const metadataTitleDescriptionComplete = {
3260
3834
  "generic-vue"
3261
3835
  ],
3262
3836
  run: async ({ sources, result }) => {
3263
- const pages = (await sources()).filter((file) => file.kind === "vue" && isPageFile(file.relPath));
3837
+ const files = await sources();
3838
+ const pages = files.filter((file) => file.kind === "vue" && isPageFile(file.relPath));
3264
3839
  if (pages.length === 0) return result.notApplicable();
3840
+ const wrappers = seoComposableNames(files);
3841
+ const wrapperCall = wrappers.size > 0 ? new RegExp(`\\b(?:${[...wrappers].join("|")})\\s*\\(`, "u") : void 0;
3265
3842
  const findings = [];
3266
3843
  for (const page of pages) {
3267
- if (!HEAD_CALL_PATTERN.test(page.text)) {
3844
+ if (REDIRECT_ONLY.test(page.text)) continue;
3845
+ if (!(HEAD_CALL_PATTERN.test(page.text) || wrapperCall?.test(page.text) === true)) {
3268
3846
  findings.push({
3269
3847
  message: "Page does not declare its own metadata.",
3270
3848
  evidence: [{ path: page.relPath }],
@@ -3560,6 +4138,72 @@ const responsiveShellPresent = {
3560
4138
  }
3561
4139
  };
3562
4140
  //#endregion
4141
+ //#region src/rules/production-polish/responsive-visibility.ts
4142
+ /**
4143
+ * `hidden md:block` shows an element from the md breakpoint up. A sibling that
4144
+ * is `md:hidden` covers the smaller range. When only one half of the pair
4145
+ * exists on a surface, one viewport range renders nothing at all.
4146
+ */
4147
+ const HIDE_AT_BREAKPOINT = /\b(?:sm|md|lg|xl|2xl):hidden\b/u;
4148
+ const SHOW_FROM_BREAKPOINT = /\bhidden\b[^"']*\b(?:sm|md|lg|xl|2xl):(?:block|flex|grid|inline)/u;
4149
+ const classOf = (element) => {
4150
+ const values = [];
4151
+ for (const prop of element.props) {
4152
+ const candidate = prop;
4153
+ if (candidate.name === "class" && typeof candidate.value?.content === "string") values.push(candidate.value.content);
4154
+ }
4155
+ return values.join(" ");
4156
+ };
4157
+ const responsiveVisibility = {
4158
+ id: "responsive-visibility",
4159
+ title: "Responsive show and hide pairs cover every viewport",
4160
+ description: "A surface that hides an element at a breakpoint should present the alternative at the complementary range, otherwise one viewport shows nothing where content is expected.",
4161
+ category: "production-polish",
4162
+ severity: "warning",
4163
+ confidence: "low",
4164
+ maxScore: 0,
4165
+ adapters: [
4166
+ "nuxt",
4167
+ "vite-vue",
4168
+ "generic-vue"
4169
+ ],
4170
+ run: async ({ sources, result }) => {
4171
+ const files = await sources();
4172
+ const findings = [];
4173
+ let evaluated = 0;
4174
+ for (const file of files) {
4175
+ if (file.sfc?.templateAst === void 0 || isGeneratedUiPrimitive(file.relPath)) continue;
4176
+ let hides = 0;
4177
+ let shows = 0;
4178
+ let firstHideLine;
4179
+ walkTemplate(file.sfc.templateAst, (element) => {
4180
+ const classes = classOf(element);
4181
+ if (classes === "") return;
4182
+ if (SHOW_FROM_BREAKPOINT.test(classes)) {
4183
+ shows += 1;
4184
+ return;
4185
+ }
4186
+ if (HIDE_AT_BREAKPOINT.test(classes)) {
4187
+ hides += 1;
4188
+ firstHideLine ??= elementLine(element);
4189
+ }
4190
+ });
4191
+ if (hides === 0 && shows === 0) continue;
4192
+ evaluated += 1;
4193
+ if (hides > 0 && shows === 0) findings.push({
4194
+ message: "This surface hides content at a breakpoint without a counterpart shown at the complementary range.",
4195
+ evidence: [{
4196
+ path: file.relPath,
4197
+ line: firstHideLine ?? 1
4198
+ }],
4199
+ remediation: "Pair each breakpoint-hidden element with the alternative for the other range (for example `md:hidden` beside `hidden md:block`), or confirm the omission is deliberate."
4200
+ });
4201
+ }
4202
+ if (evaluated === 0) return result.notApplicable();
4203
+ return findings.length > 0 ? result.advisory(findings) : result.pass();
4204
+ }
4205
+ };
4206
+ //#endregion
3563
4207
  //#region src/rules/production-polish/social-preview-present.ts
3564
4208
  const OG_IMAGE_PATTERN = /(?:og:image|ogImage|twitter:image|twitterImage)/u;
3565
4209
  const OG_TITLE_PATTERN = /(?:og:title|ogTitle|twitter:title|twitterCard|twitter:card)/u;
@@ -3588,8 +4232,10 @@ const defaultRules = [
3588
4232
  focusVisibleNotSuppressed,
3589
4233
  itemsBelongToGroups,
3590
4234
  destructiveActionsConfirmed,
4235
+ typingTargetGuard,
3591
4236
  toastProviderPresent,
3592
4237
  toastProviderMounted,
4238
+ toastRuntime,
3593
4239
  routeLoadingBoundaryPresent,
3594
4240
  suspenseFallbackUseful,
3595
4241
  emptyStatePresent,
@@ -3607,6 +4253,10 @@ const defaultRules = [
3607
4253
  iframesHaveTitle,
3608
4254
  interactiveElementsAreSemantic,
3609
4255
  noNestedInteractiveControls,
4256
+ customControlsHaveLabels,
4257
+ statusMessagesAnnounced,
4258
+ dialogFocusTrapWorks,
4259
+ keyboardNavigationWorks,
3610
4260
  formsHaveLabels,
3611
4261
  fieldErrorsRendered,
3612
4262
  invalidFieldsAssociatedWithErrors,
@@ -3614,6 +4264,7 @@ const defaultRules = [
3614
4264
  groupedControlsHaveLegend,
3615
4265
  personalDataAutocompletePresent,
3616
4266
  validationWiredToForm,
4267
+ inputGroupComposition,
3617
4268
  noStarterCopy,
3618
4269
  metadataTitleDescriptionComplete,
3619
4270
  {
@@ -3649,7 +4300,10 @@ const defaultRules = [
3649
4300
  mobileOverflowAbsent,
3650
4301
  animationsRespectReducedMotion,
3651
4302
  pointerTargetSizePasses,
3652
- buttonIconsHaveDataIcon
4303
+ buttonIconsHaveDataIcon,
4304
+ alertAnatomy,
4305
+ responsiveVisibility,
4306
+ colorContrastPasses
3653
4307
  ];
3654
4308
  //#endregion
3655
4309
  //#region src/scan.ts