pasika 0.3.4 → 0.3.6

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/cli/index.js CHANGED
@@ -2,12 +2,12 @@
2
2
 
3
3
  // cli/index.ts
4
4
  import { existsSync } from "fs";
5
- import path39 from "path";
5
+ import path41 from "path";
6
6
  import { Command } from "commander";
7
7
 
8
8
  // enforcement/coverage.ts
9
9
  import { readdirSync as readdirSync4, readFileSync as readFileSync7, writeFileSync, statSync as statSync5 } from "fs";
10
- import path38 from "path";
10
+ import path40 from "path";
11
11
  import { z as z3 } from "zod";
12
12
 
13
13
  // eslint/pasika/rules/filename-case.ts
@@ -598,8 +598,8 @@ var enforceCvaVariantPropsRule = {
598
598
  if (node.typeAnnotation?.type !== "TSTypeLiteral") return;
599
599
  for (const member of node.typeAnnotation.members ?? []) {
600
600
  if (member.type !== "TSPropertySignature") continue;
601
- const keyName = member.key?.type === "Identifier" ? member.key.name : void 0;
602
- if (!keyName) continue;
601
+ const keyName2 = member.key?.type === "Identifier" ? member.key.name : void 0;
602
+ if (!keyName2) continue;
603
603
  const typeAnn = member.typeAnnotation?.typeAnnotation;
604
604
  if (typeAnn?.type !== "TSUnionType") continue;
605
605
  const allStringLiterals = (typeAnn.types ?? []).every(
@@ -607,10 +607,10 @@ var enforceCvaVariantPropsRule = {
607
607
  );
608
608
  if (allStringLiterals && (typeAnn.types?.length ?? 0) >= 2) {
609
609
  for (const variantNames of cvaDefinitions.values()) {
610
- if (variantNames.includes(keyName)) {
610
+ if (variantNames.includes(keyName2)) {
611
611
  context.report({
612
612
  node,
613
- message: `Variant prop "${keyName}" duplicates CVA variant values. Use VariantProps<typeof variants> instead of manually writing union types. See docs/styling-guide/rules/component-variant-rule.md`
613
+ message: `Variant prop "${keyName2}" duplicates CVA variant values. Use VariantProps<typeof variants> instead of manually writing union types. See docs/styling-guide/rules/component-variant-rule.md`
614
614
  });
615
615
  return;
616
616
  }
@@ -1346,21 +1346,27 @@ var dataTestIdCaseRule = {
1346
1346
  Program(node) {
1347
1347
  for (const component of components) {
1348
1348
  const root = findSimpleRoot(component, text, filename);
1349
- if (!root) continue;
1350
- const { value } = getTestId(root);
1351
- const expected = component.smart ? component.name : toKebabCase(component.name);
1352
- if (component.smart && value === void 0) {
1353
- context.report({
1354
- node,
1355
- message: `Smart component "${component.name}" with one outer DOM element must set data-testid="${expected}".`
1356
- });
1357
- continue;
1358
- }
1359
- if (value === void 0 || value === expected) continue;
1360
- if (component.smart ? !isPascalCase5(value) || value !== expected : !isKebabCase3(value)) {
1349
+ if (root) {
1350
+ const { value } = getTestId(root);
1351
+ const expected = component.smart ? component.name : toKebabCase(component.name);
1352
+ if (component.smart && value === void 0) {
1353
+ context.report({
1354
+ node,
1355
+ message: `Smart component "${component.name}" with one outer DOM element must set data-testid="${expected}".`
1356
+ });
1357
+ continue;
1358
+ }
1359
+ if (value === void 0 || value === expected) continue;
1360
+ if (component.smart ? !isPascalCase5(value) || value !== expected : !isKebabCase3(value)) {
1361
+ context.report({
1362
+ node,
1363
+ message: `data-testid for ${component.smart ? "smart" : "dumb"} component "${component.name}" must be ${component.smart ? "PascalCase" : "kebab-case"}: expected "${expected}".`
1364
+ });
1365
+ }
1366
+ } else if (component.smart) {
1361
1367
  context.report({
1362
1368
  node,
1363
- message: `data-testid for ${component.smart ? "smart" : "dumb"} component "${component.name}" must be ${component.smart ? "PascalCase" : "kebab-case"}: expected "${expected}".`
1369
+ message: `Smart component "${component.name}" has no single outer element; wrap its content in one outer element with data-testid="${component.name}" instead of rendering multiple roots. See docs/code-organization-guide/rules/smart-vs-dumb-component-rule.md`
1364
1370
  });
1365
1371
  }
1366
1372
  }
@@ -1843,14 +1849,14 @@ var cvaAppearancePropsRule = {
1843
1849
  if (node.typeAnnotation?.type !== "TSTypeLiteral") return;
1844
1850
  for (const member of node.typeAnnotation.members ?? []) {
1845
1851
  if (member.type !== "TSPropertySignature") continue;
1846
- const keyName = member.key?.type === "Identifier" ? member.key.name : void 0;
1847
- if (!keyName || !VISUAL_OPTION_NAMES.has(keyName)) continue;
1852
+ const keyName2 = member.key?.type === "Identifier" ? member.key.name : void 0;
1853
+ if (!keyName2 || !VISUAL_OPTION_NAMES.has(keyName2)) continue;
1848
1854
  const typeAnnotation = member.typeAnnotation?.typeAnnotation ?? member.typeAnnotation;
1849
1855
  if (!typeAnnotation) continue;
1850
1856
  if (typeAnnotation.type === "TSUnionType" || typeAnnotation.type === "TSTypeLiteral") {
1851
1857
  context.report({
1852
1858
  node,
1853
- message: `Visual option prop "${keyName}" must be defined through a cva() call rather than a manual union type. See docs/styling-guide/rules/component-variant-rule.md`
1859
+ message: `Visual option prop "${keyName2}" must be defined through a cva() call rather than a manual union type. See docs/styling-guide/rules/component-variant-rule.md`
1854
1860
  });
1855
1861
  }
1856
1862
  }
@@ -1870,15 +1876,15 @@ function extractCvaInfo(node) {
1870
1876
  if (options?.type !== "ObjectExpression") return { variantNames, compoundVariants };
1871
1877
  for (const prop of options.properties) {
1872
1878
  if (prop.type !== "Property") continue;
1873
- const keyName = prop.key.type === "Identifier" ? prop.key.name : void 0;
1874
- if (keyName === "variants" && prop.value.type === "ObjectExpression") {
1879
+ const keyName2 = prop.key.type === "Identifier" ? prop.key.name : void 0;
1880
+ if (keyName2 === "variants" && prop.value.type === "ObjectExpression") {
1875
1881
  for (const vProp of prop.value.properties) {
1876
1882
  if (vProp.type !== "Property") continue;
1877
1883
  const vName = vProp.key.type === "Identifier" ? vProp.key.name : void 0;
1878
1884
  if (vName) variantNames.add(vName);
1879
1885
  }
1880
1886
  }
1881
- if (keyName === "compoundVariants" && prop.value.type === "ArrayExpression") {
1887
+ if (keyName2 === "compoundVariants" && prop.value.type === "ArrayExpression") {
1882
1888
  for (const entry of prop.value.elements) {
1883
1889
  if (entry?.type !== "ObjectExpression") continue;
1884
1890
  for (const cvProp of entry.properties) {
@@ -2649,8 +2655,279 @@ var localePlacementRule = {
2649
2655
  }
2650
2656
  };
2651
2657
 
2652
- // eslint/pasika/rules/shared-style-dedup.ts
2658
+ // eslint/pasika/rules/sole-state-owner.ts
2653
2659
  import path29 from "path";
2660
+ import ts6 from "typescript";
2661
+ function findStateHooks(node) {
2662
+ const hooks = [];
2663
+ const visit = (child) => {
2664
+ if (ts6.isCallExpression(child) && ts6.isIdentifier(child.expression) && child.expression.text === "useState") {
2665
+ if (ts6.isVariableDeclaration(child.parent)) {
2666
+ const { name } = child.parent;
2667
+ if (ts6.isArrayBindingPattern(name) && name.elements.length >= 2) {
2668
+ const value = name.elements[0];
2669
+ const updater = name.elements[1];
2670
+ if (value && updater && ts6.isBindingElement(value) && ts6.isBindingElement(updater)) {
2671
+ const valueName = value.name;
2672
+ const updaterName = updater.name;
2673
+ if (ts6.isIdentifier(valueName) && ts6.isIdentifier(updaterName)) {
2674
+ hooks.push({ value: valueName.text, updater: updaterName.text });
2675
+ }
2676
+ }
2677
+ }
2678
+ }
2679
+ }
2680
+ ts6.forEachChild(child, visit);
2681
+ };
2682
+ visit(node);
2683
+ return hooks;
2684
+ }
2685
+ function isHookUsage(node, hook) {
2686
+ if (ts6.isCallExpression(node) && ts6.isIdentifier(node.expression) && node.expression.text === hook.updater) {
2687
+ return "updater";
2688
+ }
2689
+ if (ts6.isIdentifier(node) && node.text === hook.value) {
2690
+ const parent = node.parent;
2691
+ if (ts6.isBindingElement(parent) || ts6.isPropertyAccessExpression(parent) || ts6.isShorthandPropertyAssignment(parent)) {
2692
+ return void 0;
2693
+ }
2694
+ return "value";
2695
+ }
2696
+ return void 0;
2697
+ }
2698
+ function topLevelJsxChildren(initial) {
2699
+ if (!initial) return void 0;
2700
+ let expression = initial;
2701
+ while (ts6.isParenthesizedExpression(expression)) expression = expression.expression;
2702
+ if (ts6.isJsxFragment(expression)) {
2703
+ const children = expression.children.filter(
2704
+ (c) => !ts6.isJsxText(c) && !ts6.isJsxSpreadAttribute(c)
2705
+ );
2706
+ return { root: expression, children };
2707
+ }
2708
+ if (ts6.isJsxElement(expression)) {
2709
+ const children = expression.children.filter(
2710
+ (c) => !ts6.isJsxText(c) && !ts6.isJsxSpreadAttribute(c)
2711
+ );
2712
+ return { root: expression, children };
2713
+ }
2714
+ return void 0;
2715
+ }
2716
+ function collectUsesIn(child, hook) {
2717
+ const positions = [];
2718
+ let count = 0;
2719
+ const visit = (node) => {
2720
+ if (isHookUsage(node, hook)) {
2721
+ positions.push(node);
2722
+ count += 1;
2723
+ }
2724
+ if (ts6.isFunctionDeclaration(node) || ts6.isClassDeclaration(node)) return;
2725
+ ts6.forEachChild(node, visit);
2726
+ };
2727
+ visit(child);
2728
+ return { positions, count };
2729
+ }
2730
+ var soleStateOwnerRule = {
2731
+ meta: {
2732
+ schema: [],
2733
+ type: "problem",
2734
+ docs: {
2735
+ description: "Require extracting a JSX part that is the sole owner of a useState hook as a named component."
2736
+ }
2737
+ },
2738
+ create(context) {
2739
+ const filename = path29.resolve(context.filename);
2740
+ if (!filename.endsWith(".tsx") && !filename.endsWith(".jsx")) return {};
2741
+ const text = context.sourceCode.text;
2742
+ const components = parseComponentInfo(text, filename);
2743
+ return {
2744
+ Program(node) {
2745
+ for (const component of components) {
2746
+ const hooks = findStateHooks(component.declaration);
2747
+ for (const hook of hooks) {
2748
+ const result = analyzeSoleOwner(component.declaration, hook);
2749
+ if (!result) continue;
2750
+ const { run, totalTopLevel } = result;
2751
+ if (run === 0 || totalTopLevel === 0) continue;
2752
+ context.report({
2753
+ node,
2754
+ loc: { line: 1, column: 0 },
2755
+ message: `Component "${component.name}" uses state "${hook.value}" in ${String(run)} contiguous top-level JSX part${run === 1 ? "" : "s"}; extract that part into a named component that owns useState instead of reading it in the parent. See docs/code-organization-guide/rules/sole-state-owner-rule.md`
2756
+ });
2757
+ }
2758
+ }
2759
+ }
2760
+ };
2761
+ }
2762
+ };
2763
+ function analyzeSoleOwner(declaration, hook) {
2764
+ const body = ts6.isFunctionDeclaration(declaration) ? declaration.body : void 0;
2765
+ if (!body || !ts6.isBlock(body)) return void 0;
2766
+ const returns = [];
2767
+ const visit = (node) => {
2768
+ if (node !== body && (ts6.isFunctionLike(node) || ts6.isClassLike(node))) return;
2769
+ if (ts6.isReturnStatement(node)) returns.push(node);
2770
+ ts6.forEachChild(node, visit);
2771
+ };
2772
+ visit(body);
2773
+ if (returns.length !== 1) return void 0;
2774
+ const single = returns[0];
2775
+ if (!single?.expression || !ts6.isExpression(single.expression)) return void 0;
2776
+ const returnExpression = single.expression;
2777
+ const root = topLevelJsxChildren(returnExpression);
2778
+ if (!root || root.children.length === 0) return void 0;
2779
+ const totalTopLevel = root.children.length;
2780
+ const usagePerChild = root.children.map((child) => collectUsesIn(child, hook));
2781
+ const outsideJsx = usesOutsideJsx(declaration, hook, root.children);
2782
+ if (outsideJsx) return void 0;
2783
+ const usedLabels = usagePerChild.map((u) => u.count > 0);
2784
+ const firstUsed = usedLabels.findIndex((v) => v);
2785
+ const lastUsed = usedLabels.lastIndexOf(true);
2786
+ if (firstUsed === -1 || lastUsed === -1) return void 0;
2787
+ const windowHasUnused = usedLabels.slice(firstUsed, lastUsed + 1).some((v) => !v);
2788
+ if (windowHasUnused) return void 0;
2789
+ const run = lastUsed - firstUsed + 1;
2790
+ const hasOutside = firstUsed > 0 || lastUsed < totalTopLevel - 1;
2791
+ if (!hasOutside) return void 0;
2792
+ return { run, totalTopLevel };
2793
+ }
2794
+ function usesOutsideJsx(declaration, hook, children) {
2795
+ let outside = false;
2796
+ const visit = (node) => {
2797
+ if (outside) return;
2798
+ if (node !== declaration && (ts6.isFunctionDeclaration(node) || ts6.isClassDeclaration(node))) return;
2799
+ if (isHookUsage(node, hook)) {
2800
+ let current = node;
2801
+ let isInJsxChild = false;
2802
+ while (!ts6.isSourceFile(current)) {
2803
+ if (children.includes(current)) {
2804
+ isInJsxChild = true;
2805
+ break;
2806
+ }
2807
+ current = current.parent;
2808
+ }
2809
+ if (!isInJsxChild) outside = true;
2810
+ }
2811
+ ts6.forEachChild(node, visit);
2812
+ };
2813
+ visit(declaration);
2814
+ return outside;
2815
+ }
2816
+
2817
+ // eslint/pasika/rules/locale-key-shape.ts
2818
+ import path30 from "path";
2819
+ var MAX_KEY_LENGTH = 30;
2820
+ var ROLE_POSTFIXES = /* @__PURE__ */ new Set([
2821
+ "Button",
2822
+ "Link",
2823
+ "Dialog",
2824
+ "AlertDialog",
2825
+ "Tooltip",
2826
+ "Banner",
2827
+ "Alert",
2828
+ "Status",
2829
+ "Tab",
2830
+ "TabPanel",
2831
+ "Menu",
2832
+ "MenuItem",
2833
+ "MenuBar",
2834
+ "Checkbox",
2835
+ "Radio",
2836
+ "RadioGroup",
2837
+ "Switch",
2838
+ "ComboBox",
2839
+ "ListBox",
2840
+ "Option",
2841
+ "Search",
2842
+ "SearchBox",
2843
+ "Navigation",
2844
+ "ProgressBar",
2845
+ "Slider",
2846
+ "SpinButton",
2847
+ "TextBox",
2848
+ "Tree",
2849
+ "TreeItem",
2850
+ "TreeGrid",
2851
+ "Grid",
2852
+ "GridCell",
2853
+ "Feed",
2854
+ "Log",
2855
+ "Timer",
2856
+ "Marquee",
2857
+ "Region",
2858
+ "Main",
2859
+ "Complementary",
2860
+ "ContentInfo",
2861
+ "Form",
2862
+ "Toolbar",
2863
+ "ScrollBar"
2864
+ ]);
2865
+ var CAMEL_CASE = /^[a-z][a-zA-Z0-9]*$/;
2866
+ function isLocalesFile2(filename) {
2867
+ const segments = path30.resolve(filename).split(path30.sep);
2868
+ const srcIdx = segments.lastIndexOf("src");
2869
+ return srcIdx !== -1 && segments[srcIdx + 1] === "locales";
2870
+ }
2871
+ function keyName(key) {
2872
+ if (key.type === "Identifier") return key.name;
2873
+ if (key.type === "Literal" && typeof key.value === "string") return key.value;
2874
+ return void 0;
2875
+ }
2876
+ function reportFor(context, node, message) {
2877
+ context.report({ node, message });
2878
+ }
2879
+ function checkKey(context, node, name) {
2880
+ if (!CAMEL_CASE.test(name)) {
2881
+ reportFor(
2882
+ context,
2883
+ node,
2884
+ `Locale key "${name}" must be camelCase English based on the text. See docs/code-organization-guide/rules/locales-rule.md`
2885
+ );
2886
+ return;
2887
+ }
2888
+ if (name.length <= MAX_KEY_LENGTH) return;
2889
+ const hasRolePostfix = [...ROLE_POSTFIXES].some((role) => name.endsWith(role));
2890
+ if (hasRolePostfix) return;
2891
+ reportFor(
2892
+ context,
2893
+ node,
2894
+ `Locale key "${name}" is longer than ${String(MAX_KEY_LENGTH)} characters, so it must describe the message's purpose instead of the text; end it with an element role such as "Button", "Link", or "Dialog" (see WAI-ARIA roles). See docs/code-organization-guide/rules/locales-rule.md`
2895
+ );
2896
+ }
2897
+ var localeKeyShapeRule = {
2898
+ meta: {
2899
+ schema: [],
2900
+ type: "problem",
2901
+ docs: {
2902
+ description: "Require locale keys to be camelCase, and purpose-based element roles when long."
2903
+ }
2904
+ },
2905
+ create(context) {
2906
+ if (!isLocalesFile2(context.filename)) return {};
2907
+ function visitObject(node) {
2908
+ for (const property of node.properties) {
2909
+ if (property.type !== "Property") continue;
2910
+ const name = keyName(property.key);
2911
+ if (name === void 0) continue;
2912
+ if (property.value.type === "ObjectExpression") {
2913
+ visitObject(property.value);
2914
+ } else if (property.value.type === "Literal" && typeof property.value.value === "string") {
2915
+ checkKey(context, property, name);
2916
+ }
2917
+ }
2918
+ }
2919
+ return {
2920
+ VariableDeclarator(node) {
2921
+ if (node.id.type !== "Identifier" || node.id.name !== "locales") return;
2922
+ if (node.init?.type !== "ObjectExpression") return;
2923
+ visitObject(node.init);
2924
+ }
2925
+ };
2926
+ }
2927
+ };
2928
+
2929
+ // eslint/pasika/rules/shared-style-dedup.ts
2930
+ import path31 from "path";
2654
2931
  import { readFileSync as readFileSync3, statSync as statSync2 } from "fs";
2655
2932
  var CLASS_NAME = /className="(?<classes>[^"]+)"/g;
2656
2933
  var comboCache;
@@ -2691,8 +2968,8 @@ var sharedStyleDedupRule = {
2691
2968
  }
2692
2969
  },
2693
2970
  create(context) {
2694
- const sourceRoot2 = path29.resolve("src");
2695
- const file = path29.resolve(context.filename);
2971
+ const sourceRoot2 = path31.resolve("src");
2972
+ const file = path31.resolve(context.filename);
2696
2973
  const segments = segmentsOf(file, sourceRoot2);
2697
2974
  if (segments.length === 0) return {};
2698
2975
  const index = getProjectIndex(sourceRoot2);
@@ -2714,28 +2991,111 @@ var sharedStyleDedupRule = {
2714
2991
  }
2715
2992
  };
2716
2993
 
2717
- // eslint/pasika/rules/no-eslint-disable.ts
2718
- var noEslintDisableRule = {
2994
+ // eslint/pasika/rules/repeated-structure.ts
2995
+ function nodeKind(node) {
2996
+ return node.type;
2997
+ }
2998
+ function isJsxNode(node) {
2999
+ const kind = nodeKind(node);
3000
+ return kind === "JSXElement" || kind === "JSXFragment";
3001
+ }
3002
+ function isJsxExpressionContainer(node) {
3003
+ return node.type === "JSXExpressionContainer";
3004
+ }
3005
+ function walkJsx(current, visit) {
3006
+ if (isJsxNode(current)) {
3007
+ visit(current);
3008
+ for (const child of current.children ?? []) walkJsx(child, visit);
3009
+ } else if (isJsxExpressionContainer(current)) {
3010
+ if (current.expression) walkJsx(current.expression, visit);
3011
+ } else if (current.type === "ArrowFunctionExpression") {
3012
+ walkJsx(current.body, visit);
3013
+ } else if (current.type === "CallExpression") {
3014
+ for (const argument of current.arguments) walkJsx(argument, visit);
3015
+ } else if (current.type === "ConditionalExpression") {
3016
+ walkJsx(current.consequent, visit);
3017
+ walkJsx(current.alternate, visit);
3018
+ } else if (current.type === "LogicalExpression") {
3019
+ walkJsx(current.left, visit);
3020
+ walkJsx(current.right, visit);
3021
+ }
3022
+ }
3023
+ function signature(node) {
3024
+ const parts = [];
3025
+ const visit = (current) => {
3026
+ const opening = nodeKind(current) === "JSXFragment" ? "<>" : current.openingElement?.name.name ?? "<>";
3027
+ parts.push(opening);
3028
+ if (nodeKind(current) === "JSXElement") {
3029
+ const attributes = (current.openingElement?.attributes ?? []).filter((attribute) => attribute.type === "JSXAttribute").map((attribute) => {
3030
+ const name = attribute.name?.name ?? "";
3031
+ if (name === "className" && attribute.value?.type === "Literal" && typeof attribute.value.value === "string") {
3032
+ return `class="${attribute.value.value.split(/\s+/).filter(Boolean).sort().join(" ")}"`;
3033
+ }
3034
+ return name;
3035
+ }).sort();
3036
+ parts.push(`[${attributes.join(",")}]`);
3037
+ }
3038
+ for (const child of current.children ?? []) {
3039
+ if (isJsxNode(child)) {
3040
+ visit(child);
3041
+ } else if (isJsxExpressionContainer(child)) {
3042
+ walkJsx(child, visit);
3043
+ parts.push("{}");
3044
+ } else {
3045
+ parts.push("{}");
3046
+ }
3047
+ }
3048
+ };
3049
+ visit(node);
3050
+ return parts.join("/");
3051
+ }
3052
+ function isSubstantial(node) {
3053
+ let count = 0;
3054
+ walkJsx(node, () => {
3055
+ count += 1;
3056
+ });
3057
+ return count >= 3;
3058
+ }
3059
+ function blockChildren(node) {
3060
+ return (node.children ?? []).filter(isJsxNode);
3061
+ }
3062
+ var repeatedStructureRule = {
2719
3063
  meta: {
2720
3064
  schema: [],
2721
3065
  type: "problem",
2722
3066
  docs: {
2723
- description: "Forbid eslint-disable directives."
3067
+ description: "Require extracting a repeated block of elements as a named component."
2724
3068
  }
2725
3069
  },
2726
3070
  create(context) {
3071
+ if (!context.filename.endsWith(".tsx") && !context.filename.endsWith(".jsx")) return {};
2727
3072
  return {
2728
- Program() {
2729
- for (const comment of context.sourceCode.getAllComments()) {
2730
- if (!comment.value.includes("eslint-disable")) continue;
2731
- if (!comment.loc) continue;
2732
- context.report({
2733
- loc: comment.loc,
2734
- message: "Do not use eslint-disable directives; fix the reported violation instead."
2735
- });
2736
- }
3073
+ JSXElement(node) {
3074
+ checkChildren(node);
3075
+ },
3076
+ JSXFragment(node) {
3077
+ checkChildren(node);
2737
3078
  }
2738
3079
  };
3080
+ function checkChildren(node) {
3081
+ const children = blockChildren(node);
3082
+ const seen = /* @__PURE__ */ new Map();
3083
+ for (const child of children) {
3084
+ const sig = signature(child);
3085
+ const group = seen.get(sig) ?? [];
3086
+ group.push(child);
3087
+ seen.set(sig, group);
3088
+ }
3089
+ for (const [, group] of seen) {
3090
+ if (group.length < 2) continue;
3091
+ const first = group[0];
3092
+ if (!first || !isSubstantial(first)) continue;
3093
+ context.report({
3094
+ node: first,
3095
+ message: `The same arrangement of elements appears ${String(group.length)} times here; extract it as a named component. Different data or labels do not prevent extraction. See docs/code-organization-guide/rules/repeated-structure-rule.md`
3096
+ });
3097
+ }
3098
+ }
2739
3099
  }
2740
3100
  };
2741
3101
 
@@ -2813,7 +3173,7 @@ var zodSchemaValidationRule = {
2813
3173
  };
2814
3174
 
2815
3175
  // eslint/pasika/rules/source-under-src.ts
2816
- import path30 from "path";
3176
+ import path32 from "path";
2817
3177
  var NON_SOURCE_ROOT_DIRS = /* @__PURE__ */ new Set([
2818
3178
  ".agents",
2819
3179
  ".cache",
@@ -2854,14 +3214,14 @@ var sourceUnderSrcRule = {
2854
3214
  }
2855
3215
  },
2856
3216
  create(context) {
2857
- const filename = path30.resolve(context.filename);
3217
+ const filename = path32.resolve(context.filename);
2858
3218
  if (!MODULE_EXTENSION.test(filename)) return {};
2859
- const relative = path30.relative(process.cwd(), filename).replace(/\\/g, "/");
3219
+ const relative = path32.relative(process.cwd(), filename).replace(/\\/g, "/");
2860
3220
  if (relative === "src" || relative.startsWith("src/")) return {};
2861
3221
  const topLevel = relative.split("/")[0] ?? "";
2862
3222
  if (NON_SOURCE_ROOT_DIRS.has(topLevel)) return {};
2863
3223
  if (!relative.includes("/")) {
2864
- const basename = path30.basename(filename);
3224
+ const basename = path32.basename(filename);
2865
3225
  if (CONFIG_FILE.test(basename) || DECLARATION_FILE.test(basename) || basename.startsWith(".")) return {};
2866
3226
  }
2867
3227
  return {
@@ -2923,7 +3283,7 @@ var docKindSuffixRule = {
2923
3283
  };
2924
3284
 
2925
3285
  // eslint/pasika/rules/md/title-matches-file-name.ts
2926
- import path31 from "path";
3286
+ import path33 from "path";
2927
3287
  function toExpectedFileName(title) {
2928
3288
  return `${title.toLowerCase().replaceAll(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")}.md`;
2929
3289
  }
@@ -2943,7 +3303,7 @@ var titleMatchesFileNameRule = {
2943
3303
  if (!filename.endsWith(".md")) return;
2944
3304
  const title = getTextContent(node).trim();
2945
3305
  const expectedFileName = toExpectedFileName(title);
2946
- const actualFileName = path31.basename(filename);
3306
+ const actualFileName = path33.basename(filename);
2947
3307
  if (!title) {
2948
3308
  context.report({
2949
3309
  node,
@@ -3416,7 +3776,7 @@ var referenceBlockHeadingsRule = {
3416
3776
  };
3417
3777
 
3418
3778
  // eslint/pasika/rules/md/support-document-placement.ts
3419
- import path32 from "path";
3779
+ import path34 from "path";
3420
3780
  var supportDocumentPlacementRule = {
3421
3781
  meta: {
3422
3782
  type: "problem",
@@ -3430,7 +3790,7 @@ var supportDocumentPlacementRule = {
3430
3790
  root(node) {
3431
3791
  const filename = getFilename(context);
3432
3792
  if (!filename.endsWith(".md")) return;
3433
- const parentFolder = path32.basename(path32.dirname(filename));
3793
+ const parentFolder = path34.basename(path34.dirname(filename));
3434
3794
  if (filename.endsWith("-rule.md") && parentFolder !== "rules") {
3435
3795
  context.report({
3436
3796
  node,
@@ -3473,11 +3833,11 @@ var noTemplatePromptRule = {
3473
3833
  };
3474
3834
 
3475
3835
  // eslint/pasika/rules/md/guide-folder-entry-point.ts
3476
- import path34 from "path";
3836
+ import path36 from "path";
3477
3837
 
3478
3838
  // eslint/pasika/rules/md/project-index.ts
3479
3839
  import { readdirSync as readdirSync2, readFileSync as readFileSync4, statSync as statSync3 } from "fs";
3480
- import path33 from "path";
3840
+ import path35 from "path";
3481
3841
  var KIND_BY_SUFFIX = [
3482
3842
  ["-rule.md", "rule"],
3483
3843
  ["-guide.md", "guide"],
@@ -3486,7 +3846,7 @@ var KIND_BY_SUFFIX = [
3486
3846
  ];
3487
3847
  function listMarkdownFiles(dir) {
3488
3848
  return readdirSync2(dir).flatMap((entry) => {
3489
- const entryPath = path33.join(dir, entry);
3849
+ const entryPath = path35.join(dir, entry);
3490
3850
  if (statSync3(entryPath).isDirectory()) {
3491
3851
  return entry.startsWith("_") ? [] : listMarkdownFiles(entryPath);
3492
3852
  }
@@ -3504,11 +3864,11 @@ function getProjectDocs(docsRoot) {
3504
3864
  if (cached) return cached;
3505
3865
  const files = listMarkdownFiles(docsRoot);
3506
3866
  const docs = files.sort((a, b) => a.localeCompare(b)).map((filePath) => {
3507
- const fileName = path33.basename(filePath);
3867
+ const fileName = path35.basename(filePath);
3508
3868
  const kind = KIND_BY_SUFFIX.find(([suffix]) => fileName.endsWith(suffix))?.[1];
3509
3869
  return {
3510
3870
  filePath,
3511
- doc: path33.relative(docsRoot, filePath).split(path33.sep).join("/"),
3871
+ doc: path35.relative(docsRoot, filePath).split(path35.sep).join("/"),
3512
3872
  fileName,
3513
3873
  kind,
3514
3874
  title: extractTitle(filePath)
@@ -3518,12 +3878,12 @@ function getProjectDocs(docsRoot) {
3518
3878
  return docs;
3519
3879
  }
3520
3880
  function findDocsRoot(filePath) {
3521
- let dir = path33.dirname(filePath);
3881
+ let dir = path35.dirname(filePath);
3522
3882
  for (; ; ) {
3523
- if (path33.basename(dir) === "docs" && statSync3(dir).isDirectory()) {
3883
+ if (path35.basename(dir) === "docs" && statSync3(dir).isDirectory()) {
3524
3884
  return dir;
3525
3885
  }
3526
- const parent = path33.dirname(dir);
3886
+ const parent = path35.dirname(dir);
3527
3887
  if (parent === dir) return void 0;
3528
3888
  dir = parent;
3529
3889
  }
@@ -3547,13 +3907,13 @@ var guideFolderEntryPointRule = {
3547
3907
  if (!docsRoot) return;
3548
3908
  const docs = getProjectDocs(docsRoot);
3549
3909
  const guideFolders = new Set(
3550
- docs.filter((doc) => ["rules", "references"].includes(path34.basename(path34.dirname(doc.filePath)))).map((doc) => path34.dirname(path34.dirname(doc.filePath))).filter((folder) => path34.resolve(folder) !== path34.resolve(docsRoot))
3910
+ docs.filter((doc) => ["rules", "references"].includes(path36.basename(path36.dirname(doc.filePath)))).map((doc) => path36.dirname(path36.dirname(doc.filePath))).filter((folder) => path36.resolve(folder) !== path36.resolve(docsRoot))
3551
3911
  );
3552
- const currentDir = path34.dirname(filename);
3912
+ const currentDir = path36.dirname(filename);
3553
3913
  if (guideFolders.has(currentDir)) {
3554
- const expectedEntryPoint = `${path34.basename(currentDir)}.md`;
3914
+ const expectedEntryPoint = `${path36.basename(currentDir)}.md`;
3555
3915
  const hasEntryPoint = docs.some(
3556
- (doc) => doc.kind === "guide" && path34.dirname(doc.filePath) === currentDir && doc.fileName === expectedEntryPoint
3916
+ (doc) => doc.kind === "guide" && path36.dirname(doc.filePath) === currentDir && doc.fileName === expectedEntryPoint
3557
3917
  );
3558
3918
  if (!hasEntryPoint) {
3559
3919
  context.report({
@@ -3610,6 +3970,39 @@ var rfcOnlyInBulletsRule = {
3610
3970
  }
3611
3971
  };
3612
3972
 
3973
+ // eslint/pasika/rules/md/rfc-keyword-in-every-bullet.ts
3974
+ function checkBullets(node, context) {
3975
+ if (node.type === "listItem" && !containsRfcKeyword(getTextContent(node))) {
3976
+ context.report({
3977
+ node,
3978
+ message: "bullet point contains no RFC 2119 keyword"
3979
+ });
3980
+ }
3981
+ if ("children" in node) {
3982
+ for (const child of node.children) {
3983
+ checkBullets(child, context);
3984
+ }
3985
+ }
3986
+ }
3987
+ var rfcKeywordInEveryBulletRule = {
3988
+ meta: {
3989
+ type: "problem",
3990
+ docs: {
3991
+ description: "Every bullet in a rule or policy document must contain an RFC 2119 keyword.",
3992
+ recommended: true
3993
+ }
3994
+ },
3995
+ create(context) {
3996
+ return {
3997
+ root(node) {
3998
+ const filename = getFilename(context);
3999
+ if (!filename.endsWith("-rule.md") && !filename.endsWith("-policy.md")) return;
4000
+ checkBullets(node, context);
4001
+ }
4002
+ };
4003
+ }
4004
+ };
4005
+
3613
4006
  // eslint/pasika/rules/md/policy-subject-headings.ts
3614
4007
  var policySubjectHeadingsRule = {
3615
4008
  meta: {
@@ -3745,7 +4138,7 @@ var noNestedHowToRule = {
3745
4138
 
3746
4139
  // eslint/pasika/rules/md/glossary-term-linking.ts
3747
4140
  import { readFileSync as readFileSync5 } from "fs";
3748
- import path35 from "path";
4141
+ import path37 from "path";
3749
4142
  function extractGlossaryTerms(filePath) {
3750
4143
  const content = readFileSync5(filePath, "utf8");
3751
4144
  const terms = [];
@@ -3799,9 +4192,9 @@ var glossaryTermLinkingRule = {
3799
4192
  const docsRoot = findDocsRoot(filename);
3800
4193
  if (!docsRoot) return;
3801
4194
  const docs = getProjectDocs(docsRoot);
3802
- const guideDir = path35.dirname(filename);
4195
+ const guideDir = path37.dirname(filename);
3803
4196
  const guideReferences = docs.filter(
3804
- (doc) => doc.kind === "reference" && path35.dirname(doc.filePath) === guideDir
4197
+ (doc) => doc.kind === "reference" && path37.dirname(doc.filePath) === guideDir
3805
4198
  );
3806
4199
  if (guideReferences.length === 0) return;
3807
4200
  const glossaryTerms = [];
@@ -3845,6 +4238,7 @@ var mdRules = {
3845
4238
  "no-template-prompt": noTemplatePromptRule,
3846
4239
  "guide-folder-entry-point": guideFolderEntryPointRule,
3847
4240
  "rfc-only-in-bullets": rfcOnlyInBulletsRule,
4241
+ "rfc-keyword-in-every-bullet": rfcKeywordInEveryBulletRule,
3848
4242
  "policy-subject-headings": policySubjectHeadingsRule,
3849
4243
  "guide-link-anchors": guideLinkAnchorsRule,
3850
4244
  "no-nested-how-to": noNestedHowToRule,
@@ -3874,7 +4268,7 @@ function atrules(node) {
3874
4268
  function atrulesNamed(node, name) {
3875
4269
  return atrules(node).filter((candidate) => candidate.type === "Atrule" && candidate.name === name);
3876
4270
  }
3877
- function blockChildren(node) {
4271
+ function blockChildren2(node) {
3878
4272
  if (!node) return [];
3879
4273
  if (!("block" in node) || !node.block) return [];
3880
4274
  return node.block.children;
@@ -3921,7 +4315,7 @@ var themeResetRule = {
3921
4315
  return {
3922
4316
  "StyleSheet:exit"(node) {
3923
4317
  const resetFound = atrulesNamed(node, "theme").some(
3924
- (theme) => blockChildren(theme).some((child) => {
4318
+ (theme) => blockChildren2(theme).some((child) => {
3925
4319
  if (child.type === "Declaration") return child.property.startsWith("--*");
3926
4320
  if (child.type === "Raw") return child.value.includes("--*: initial");
3927
4321
  return false;
@@ -3952,7 +4346,7 @@ var rootVariablesRule = {
3952
4346
  "StyleSheet:exit"(node) {
3953
4347
  const hasRootVars = rules(node).some((rule) => {
3954
4348
  if (!selectorNames(rule).includes("root")) return false;
3955
- return blockChildren(rule).some((child) => child.type === "Declaration");
4349
+ return blockChildren2(rule).some((child) => child.type === "Declaration");
3956
4350
  });
3957
4351
  if (!hasRootVars) {
3958
4352
  context.report({
@@ -3961,7 +4355,7 @@ var rootVariablesRule = {
3961
4355
  });
3962
4356
  }
3963
4357
  const referencingThemes = atrulesNamed(node, "theme").filter(
3964
- (theme) => blockChildren(theme).some((child) => {
4358
+ (theme) => blockChildren2(theme).some((child) => {
3965
4359
  if (child.type === "Raw") return child.value.includes("var(");
3966
4360
  if (child.type === "Declaration") return JSON.stringify(child.value).includes("var(");
3967
4361
  return false;
@@ -3995,9 +4389,9 @@ var applyUsageRule = {
3995
4389
  "StyleSheet:exit"(node) {
3996
4390
  const layers = node.children.filter((child) => child.type === "Atrule" && child.name === "layer");
3997
4391
  for (const layer of layers) {
3998
- for (const child of blockChildren(layer)) {
4392
+ for (const child of blockChildren2(layer)) {
3999
4393
  if (child.type !== "Rule") continue;
4000
- for (const declaration of blockChildren(child)) {
4394
+ for (const declaration of blockChildren2(child)) {
4001
4395
  if (declaration.type !== "Declaration") continue;
4002
4396
  context.report({
4003
4397
  node: declaration,
@@ -4027,10 +4421,10 @@ var baseLayerPairRule = {
4027
4421
  if (baseLayers.length === 0) return;
4028
4422
  const applied = [];
4029
4423
  for (const layer of baseLayers) {
4030
- for (const child of blockChildren(layer)) {
4424
+ for (const child of blockChildren2(layer)) {
4031
4425
  if (child.type !== "Rule") continue;
4032
4426
  if (!selectorNames(child).includes("body")) continue;
4033
- for (const declaration of blockChildren(child)) {
4427
+ for (const declaration of blockChildren2(child)) {
4034
4428
  if (declaration.type !== "Atrule" || declaration.name !== "apply") continue;
4035
4429
  applied.push(...preludeIdentifiers(declaration));
4036
4430
  }
@@ -4155,7 +4549,7 @@ var customUtilityApplyRule = {
4155
4549
  return {
4156
4550
  "StyleSheet:exit"(node) {
4157
4551
  for (const utility of atrulesNamed(node, "utility")) {
4158
- for (const child of blockChildren(utility)) {
4552
+ for (const child of blockChildren2(utility)) {
4159
4553
  if (child.type === "Atrule" && child.name === "apply") continue;
4160
4554
  if (child.type === "Declaration") {
4161
4555
  context.report({
@@ -4227,7 +4621,7 @@ var themeVariableNamespaceRule = {
4227
4621
  "StyleSheet:exit"(node) {
4228
4622
  const namespaces = /* @__PURE__ */ new Set();
4229
4623
  for (const theme of atrulesNamed(node, "theme")) {
4230
- for (const child of blockChildren(theme)) {
4624
+ for (const child of blockChildren2(theme)) {
4231
4625
  if (child.type !== "Declaration") continue;
4232
4626
  const segments = child.property.replace(/^--/, "").split("-");
4233
4627
  if (segments[0]) namespaces.add(segments[0]);
@@ -4297,43 +4691,8 @@ var cssRules = {
4297
4691
  "global-css-location": globalCssLocationRule
4298
4692
  };
4299
4693
 
4300
- // eslint/pasika/rules/json/no-cache-flag.ts
4301
- function memberName(member) {
4302
- return member.name.type === "String" ? member.name.value : member.name.name;
4303
- }
4304
- var noCacheFlagRule = {
4305
- meta: {
4306
- schema: [],
4307
- type: "problem",
4308
- docs: {
4309
- description: "Require lint scripts to not pass ESLint's --cache flag."
4310
- }
4311
- },
4312
- create(context) {
4313
- return {
4314
- Document(node) {
4315
- const root = node.body;
4316
- if (root.type !== "Object") return;
4317
- const scripts = root.members.find((member) => memberName(member) === "scripts");
4318
- if (scripts?.value.type !== "Object") return;
4319
- for (const member of scripts.value.members) {
4320
- const name = memberName(member);
4321
- if (name !== "lint" && name !== "fix") continue;
4322
- if (member.value.type !== "String") continue;
4323
- if (member.value.value.includes("--cache")) {
4324
- context.report({
4325
- node: member,
4326
- message: "Lint scripts must not pass ESLint's --cache flag because cross-file rules require every file in the run."
4327
- });
4328
- }
4329
- }
4330
- }
4331
- };
4332
- }
4333
- };
4334
-
4335
4694
  // eslint/pasika/rules/json/no-vulyk-dependency.ts
4336
- function memberName2(member) {
4695
+ function memberName(member) {
4337
4696
  return member.name.type === "String" ? member.name.value : member.name.name;
4338
4697
  }
4339
4698
  var noVulykDependencyRule = {
@@ -4350,9 +4709,9 @@ var noVulykDependencyRule = {
4350
4709
  const root = node.body;
4351
4710
  if (root.type !== "Object") return;
4352
4711
  for (const key of ["dependencies", "devDependencies"]) {
4353
- const section = root.members.find((member) => memberName2(member) === key);
4712
+ const section = root.members.find((member) => memberName(member) === key);
4354
4713
  if (section?.value.type !== "Object") continue;
4355
- const vulyk = section.value.members.find((member) => memberName2(member) === "vulyk");
4714
+ const vulyk = section.value.members.find((member) => memberName(member) === "vulyk");
4356
4715
  if (vulyk) {
4357
4716
  context.report({
4358
4717
  node: vulyk,
@@ -4367,7 +4726,6 @@ var noVulykDependencyRule = {
4367
4726
 
4368
4727
  // eslint/pasika/rules/json/index.ts
4369
4728
  var jsonRules = {
4370
- "no-cache-flag": noCacheFlagRule,
4371
4729
  "no-vulyk-dependency": noVulykDependencyRule
4372
4730
  };
4373
4731
 
@@ -4406,8 +4764,10 @@ var pasikaRules = {
4406
4764
  "stay-flat": stayFlatRule,
4407
4765
  "type-extraction": typeExtractionRule,
4408
4766
  "locale-placement": localePlacementRule,
4767
+ "sole-state-owner": soleStateOwnerRule,
4768
+ "locale-key-shape": localeKeyShapeRule,
4409
4769
  "shared-style-dedup": sharedStyleDedupRule,
4410
- "no-eslint-disable": noEslintDisableRule,
4770
+ "repeated-structure": repeatedStructureRule,
4411
4771
  "zod-schema-validation": zodSchemaValidationRule,
4412
4772
  "source-under-src": sourceUnderSrcRule
4413
4773
  };
@@ -4435,7 +4795,7 @@ function hashRequirement(canonicalText) {
4435
4795
 
4436
4796
  // enforcement/parse-docs.ts
4437
4797
  import { readdirSync as readdirSync3, readFileSync as readFileSync6, statSync as statSync4 } from "fs";
4438
- import path36 from "path";
4798
+ import path38 from "path";
4439
4799
  var KIND_BY_SUFFIX2 = [
4440
4800
  ["-rule.md", "rule"],
4441
4801
  ["-guide.md", "guide"],
@@ -4445,7 +4805,7 @@ var KIND_BY_SUFFIX2 = [
4445
4805
  var RFC_2119 = /\b(?<keyword>MUST NOT|MUST|SHOULD NOT|SHOULD|MAY)\b/;
4446
4806
  function listMarkdownFiles2(dir) {
4447
4807
  return readdirSync3(dir).flatMap((entry) => {
4448
- const entryPath = path36.join(dir, entry);
4808
+ const entryPath = path38.join(dir, entry);
4449
4809
  if (statSync4(entryPath).isDirectory()) {
4450
4810
  return entry.startsWith("_") ? [] : listMarkdownFiles2(entryPath);
4451
4811
  }
@@ -4471,7 +4831,7 @@ function parseDoc(filePath, docsRoot) {
4471
4831
  const sourceLines = body.split("\n");
4472
4832
  const prose = toProse(body, "$<content>");
4473
4833
  const proseWithoutCode = toProse(body, "");
4474
- const fileName = path36.basename(filePath);
4834
+ const fileName = path38.basename(filePath);
4475
4835
  const kind = KIND_BY_SUFFIX2.find(([suffix]) => fileName.endsWith(suffix))?.[1];
4476
4836
  const requirements = [];
4477
4837
  const steps = [];
@@ -4501,7 +4861,7 @@ function parseDoc(filePath, docsRoot) {
4501
4861
  const overview = prose.slice(1).find((line) => line.trim() !== "" && !line.startsWith("#"));
4502
4862
  return {
4503
4863
  filePath,
4504
- doc: path36.relative(docsRoot, filePath).split(path36.sep).join("/"),
4864
+ doc: path38.relative(docsRoot, filePath).split(path38.sep).join("/"),
4505
4865
  fileName,
4506
4866
  kind,
4507
4867
  title,
@@ -4521,7 +4881,7 @@ function parseDocs(docsRoot) {
4521
4881
 
4522
4882
  // enforcement/doctor.ts
4523
4883
  import fs4 from "fs";
4524
- import path37 from "path";
4884
+ import path39 from "path";
4525
4885
  import { z } from "zod";
4526
4886
  var doctorCheckRefs = [
4527
4887
  "pasika/pasika-installed",
@@ -4565,7 +4925,7 @@ function checkFrameworkPackages(pkg) {
4565
4925
  }
4566
4926
  function checkSourceRoot(cwd) {
4567
4927
  const findings = [];
4568
- const srcDir = path37.join(cwd, "src");
4928
+ const srcDir = path39.join(cwd, "src");
4569
4929
  if (!fs4.existsSync(srcDir)) {
4570
4930
  findings.push({
4571
4931
  check: "source-under-src",
@@ -4591,14 +4951,14 @@ function checkSourceRoot(cwd) {
4591
4951
  function findGlobalStylesheet(cwd) {
4592
4952
  const candidates = ["src/app/globals.css", "src/styles/globals.css", "src/globals.css"];
4593
4953
  for (const candidate of candidates) {
4594
- const full = path37.join(cwd, candidate);
4954
+ const full = path39.join(cwd, candidate);
4595
4955
  if (fs4.existsSync(full)) return full;
4596
4956
  }
4597
- const srcDir = path37.join(cwd, "src");
4957
+ const srcDir = path39.join(cwd, "src");
4598
4958
  if (!fs4.existsSync(srcDir)) return void 0;
4599
4959
  const walk = (dir) => {
4600
4960
  for (const entry of fs4.readdirSync(dir, { withFileTypes: true })) {
4601
- const full = path37.join(dir, entry.name);
4961
+ const full = path39.join(dir, entry.name);
4602
4962
  if (entry.isDirectory() && entry.name !== "node_modules" && entry.name !== ".next") {
4603
4963
  const found = walk(full);
4604
4964
  if (found) return found;
@@ -4629,7 +4989,7 @@ function checkGlobalStylesheet(cwd) {
4629
4989
  function checkConfigBaseline(cwd) {
4630
4990
  const findings = [];
4631
4991
  const eslintConfigs = ["eslint.config.ts", "eslint.config.mjs", "eslint.config.js"];
4632
- const eslintConfig = eslintConfigs.find((name) => fs4.existsSync(path37.join(cwd, name)));
4992
+ const eslintConfig = eslintConfigs.find((name) => fs4.existsSync(path39.join(cwd, name)));
4633
4993
  if (!eslintConfig) {
4634
4994
  findings.push({
4635
4995
  check: "config-baseline",
@@ -4637,7 +4997,7 @@ function checkConfigBaseline(cwd) {
4637
4997
  severity: "error"
4638
4998
  });
4639
4999
  } else {
4640
- const content = fs4.readFileSync(path37.join(cwd, eslintConfig), "utf8");
5000
+ const content = fs4.readFileSync(path39.join(cwd, eslintConfig), "utf8");
4641
5001
  if (!content.includes("zirka")) {
4642
5002
  findings.push({
4643
5003
  check: "config-baseline",
@@ -4646,7 +5006,7 @@ function checkConfigBaseline(cwd) {
4646
5006
  });
4647
5007
  }
4648
5008
  }
4649
- if (!fs4.existsSync(path37.join(cwd, "tsconfig.json"))) {
5009
+ if (!fs4.existsSync(path39.join(cwd, "tsconfig.json"))) {
4650
5010
  findings.push({
4651
5011
  check: "config-baseline",
4652
5012
  message: "No tsconfig.json found. Create one extending the pasika baseline.",
@@ -4657,31 +5017,29 @@ function checkConfigBaseline(cwd) {
4657
5017
  }
4658
5018
  function checkManagedFiles(cwd) {
4659
5019
  const findings = [];
4660
- const vulykDir = path37.join(cwd, ".vulyk");
4661
- if (!fs4.existsSync(vulykDir)) return findings;
4662
- const manifestPath = path37.join(vulykDir, "manifest.json");
4663
- if (!fs4.existsSync(manifestPath)) return findings;
5020
+ const configPath = path39.join(cwd, "vulyk.config.ts");
5021
+ if (!fs4.existsSync(configPath)) return findings;
5022
+ const lockPath = path39.join(cwd, "vulyk.lock.json");
5023
+ if (!fs4.existsSync(lockPath)) return findings;
4664
5024
  try {
4665
- const manifestEntrySchema = z.looseObject({
4666
- targets: z.array(z.string()).optional()
4667
- });
4668
- const manifestSchema = z.record(z.string(), manifestEntrySchema);
4669
- const result = manifestSchema.safeParse(JSON.parse(fs4.readFileSync(manifestPath, "utf8")));
4670
- if (!result.success) return findings;
4671
- for (const [, entry] of Object.entries(result.data)) {
4672
- const targets = entry.targets ?? [];
4673
- for (const target of targets) {
4674
- const targetPath = path37.join(cwd, target);
4675
- if (fs4.existsSync(targetPath)) {
4676
- const stat = fs4.statSync(targetPath);
4677
- const vulykStat = fs4.statSync(manifestPath);
4678
- if (stat.mtimeMs > vulykStat.mtimeMs + 1e3) {
4679
- findings.push({
4680
- check: "managed-file-edit",
4681
- message: `File "${target}" appears to have been edited after vulyk wrote it. Use vulyk commands to update managed files.`,
4682
- severity: "warning"
4683
- });
4684
- }
5025
+ const configMtime = fs4.statSync(configPath).mtimeMs;
5026
+ const lockMtime = fs4.statSync(lockPath).mtimeMs;
5027
+ const referenceMtime = Math.max(configMtime, lockMtime);
5028
+ const defaultOutputDirs = ["docs/external"];
5029
+ for (const dir of defaultOutputDirs) {
5030
+ const dirPath = path39.join(cwd, dir);
5031
+ if (!fs4.existsSync(dirPath)) continue;
5032
+ const entries = fs4.readdirSync(dirPath, { withFileTypes: true });
5033
+ for (const entry of entries) {
5034
+ if (!entry.isFile()) continue;
5035
+ const filePath = path39.join(dirPath, entry.name);
5036
+ const stat = fs4.statSync(filePath);
5037
+ if (stat.mtimeMs > referenceMtime + 1e3) {
5038
+ findings.push({
5039
+ check: "managed-file-edit",
5040
+ message: `File "${dir}/${entry.name}" appears to have been edited after vulyk wrote it. Use vulyk commands to update managed files.`,
5041
+ severity: "warning"
5042
+ });
4685
5043
  }
4686
5044
  }
4687
5045
  }
@@ -4690,7 +5048,7 @@ function checkManagedFiles(cwd) {
4690
5048
  return findings;
4691
5049
  }
4692
5050
  function runDoctor(cwd) {
4693
- const pkgPath = path37.join(cwd, "package.json");
5051
+ const pkgPath = path39.join(cwd, "package.json");
4694
5052
  const pkg = readPackageJson(pkgPath);
4695
5053
  return [
4696
5054
  ...checkFrameworkPackages(pkg),
@@ -4738,7 +5096,7 @@ function collectTestTitles(rulesDir) {
4738
5096
  const titles = /* @__PURE__ */ new Set();
4739
5097
  const visit = (dir) => {
4740
5098
  for (const entry of readdirSync4(dir)) {
4741
- const entryPath = path38.join(dir, entry);
5099
+ const entryPath = path40.join(dir, entry);
4742
5100
  if (statSync5(entryPath).isDirectory()) {
4743
5101
  visit(entryPath);
4744
5102
  continue;
@@ -4748,7 +5106,7 @@ function collectTestTitles(rulesDir) {
4748
5106
  const titlePattern = /\b(?:describe|test|it)\(\s*(?:"(?<double>(?:[^"\\]|\\.)*)"|'(?<single>(?:[^'\\]|\\.)*)')/g;
4749
5107
  for (const match of body.matchAll(titlePattern)) {
4750
5108
  const title = match.groups?.double ?? match.groups?.single ?? "";
4751
- titles.add(title.replaceAll('\\\\"', '"').replaceAll("\\\\'", "'"));
5109
+ titles.add(title.replaceAll("`", "").replaceAll('\\\\"', '"').replaceAll("\\\\'", "'"));
4752
5110
  }
4753
5111
  }
4754
5112
  };
@@ -4921,12 +5279,12 @@ function json(data) {
4921
5279
  }
4922
5280
 
4923
5281
  // cli/index.ts
4924
- var REGISTRY_RELATIVE_PATH = path39.join("enforcement", "registry.json");
5282
+ var REGISTRY_RELATIVE_PATH = path41.join("enforcement", "registry.json");
4925
5283
  function findRegistryRoot(startDir) {
4926
- let dir = path39.resolve(startDir);
5284
+ let dir = path41.resolve(startDir);
4927
5285
  for (; ; ) {
4928
- if (existsSync(path39.join(dir, REGISTRY_RELATIVE_PATH))) return dir;
4929
- const parent = path39.dirname(dir);
5286
+ if (existsSync(path41.join(dir, REGISTRY_RELATIVE_PATH))) return dir;
5287
+ const parent = path41.dirname(dir);
4930
5288
  if (parent === dir) return void 0;
4931
5289
  dir = parent;
4932
5290
  }
@@ -4950,12 +5308,12 @@ program.command("coverage").description("Check that every documented requirement
4950
5308
  error(`No ${REGISTRY_RELATIVE_PATH} found in this directory or any parent.`);
4951
5309
  process.exit(1);
4952
5310
  }
4953
- const docsRoot = path39.join(root, "docs");
5311
+ const docsRoot = path41.join(root, "docs");
4954
5312
  if (!existsSync(docsRoot)) {
4955
5313
  error(`No documentation folder at ${docsRoot}. Run coverage inside the pasika repository.`);
4956
5314
  process.exit(1);
4957
5315
  }
4958
- const registryPath = path39.join(root, REGISTRY_RELATIVE_PATH);
5316
+ const registryPath = path41.join(root, REGISTRY_RELATIVE_PATH);
4959
5317
  if (options.classify !== void 0) {
4960
5318
  try {
4961
5319
  const result = classifyRequirement({
@@ -4974,7 +5332,7 @@ program.command("coverage").description("Check that every documented requirement
4974
5332
  const report3 = buildCoverageReport({
4975
5333
  docsRoot,
4976
5334
  registry: readRegistry(registryPath),
4977
- rulesDir: path39.join(root, "eslint", "pasika", "rules")
5335
+ rulesDir: path41.join(root, "eslint", "pasika", "rules")
4978
5336
  });
4979
5337
  if (options.json) {
4980
5338
  json(report3);