@multiplatform.one/config 7.11.0 → 7.15.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/src/lint.spec.ts CHANGED
@@ -2,7 +2,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { afterEach, describe, expect, it, vi } from "vitest";
5
- import { resolveRoots, runConventionChecks } from "../lint.mjs";
5
+ import { findSizeRecipeEscapes, resolveRoots, runConventionChecks } from "../lint.mjs";
6
6
 
7
7
  const fixtures: string[] = [];
8
8
 
@@ -551,3 +551,495 @@ export type Where = PathLike;
551
551
  expect(run(root).code).toBe(0);
552
552
  });
553
553
  });
554
+
555
+ describe("a scaffolded project, where apps/one is renamed and public/ is pruned (MPO-318)", () => {
556
+ function run(root: string) {
557
+ const error = vi.spyOn(console, "error").mockImplementation(() => {});
558
+ const log = vi.spyOn(console, "log").mockImplementation(() => {});
559
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
560
+ const code = runConventionChecks({ roots: { root } });
561
+ const errors = error.mock.calls.map((c) => String(c[0])).join("\n");
562
+ const warnings = warn.mock.calls.map((c) => String(c[0])).join("\n");
563
+ error.mockRestore();
564
+ log.mockRestore();
565
+ warn.mockRestore();
566
+ return { code, errors, warnings };
567
+ }
568
+
569
+ function animationRegistry(root: string) {
570
+ mkdirSync(join(root, "public", "theme", "src", "theme", "animations"), { recursive: true });
571
+ writeFileSync(
572
+ join(root, "public", "theme", "src", "theme", "animations", "css.ts"),
573
+ 'export const animationConfig = {\n medium: "ease-in 300ms",\n} as const;\n',
574
+ );
575
+ }
576
+
577
+ it("passes the vscode webview fonts.css in a product app that is not called one", () => {
578
+ const root = makeTree();
579
+ mkdirSync(join(root, "apps", "demo", "vscode", "webview"), { recursive: true });
580
+ writeFileSync(join(root, "apps", "demo", "vscode", "webview", "fonts.css"), "@font-face {}\n");
581
+ expect(run(root).code).toBe(0);
582
+ });
583
+
584
+ it("still fails any other .css in that vscode webview", () => {
585
+ const root = makeTree();
586
+ mkdirSync(join(root, "apps", "demo", "vscode", "webview"), { recursive: true });
587
+ writeFileSync(join(root, "apps", "demo", "vscode", "webview", "app.css"), "body {}\n");
588
+ const { code, errors } = run(root);
589
+ expect(code).toBe(1);
590
+ expect(errors).toMatch(/apps\/demo\/vscode\/webview\/app\.css/);
591
+ });
592
+
593
+ it("skips the transition check, and says so, when the animation registry is not in the tree", () => {
594
+ const root = makeTree();
595
+ writeFileSync(
596
+ join(root, "apps", "demo", "App.tsx"),
597
+ 'export const App = () => <View transition="medium" />;\n',
598
+ );
599
+ const { code, warnings } = run(root);
600
+ expect(code).toBe(0);
601
+ expect(warnings).toMatch(/skipped the animation token check/);
602
+ });
603
+
604
+ it("still fails an unregistered transition when the registry is readable", () => {
605
+ const root = makeTree();
606
+ animationRegistry(root);
607
+ writeFileSync(
608
+ join(root, "apps", "demo", "App.tsx"),
609
+ 'export const App = () => <View transition="mediun" />;\n',
610
+ );
611
+ const { code, errors } = run(root);
612
+ expect(code).toBe(1);
613
+ expect(errors).toMatch(/apps\/demo\/App\.tsx:1 — transition="mediun"/);
614
+ });
615
+
616
+ it("passes a registered transition when the registry is readable", () => {
617
+ const root = makeTree();
618
+ animationRegistry(root);
619
+ writeFileSync(
620
+ join(root, "apps", "demo", "App.tsx"),
621
+ 'export const App = () => <View transition="medium" />;\n',
622
+ );
623
+ const { code, warnings } = run(root);
624
+ expect(code).toBe(0);
625
+ expect(warnings).not.toMatch(/animation token/);
626
+ });
627
+ });
628
+
629
+ describe("size-recipe escape registry (MPO-23, 00 L8 E8)", () => {
630
+ function run(root: string) {
631
+ const error = vi.spyOn(console, "error").mockImplementation(() => {});
632
+ const log = vi.spyOn(console, "log").mockImplementation(() => {});
633
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
634
+ const code = runConventionChecks({ roots: { root } });
635
+ const messages = error.mock.calls.map((call) => String(call[0])).join("\n");
636
+ error.mockRestore();
637
+ log.mockRestore();
638
+ warn.mockRestore();
639
+ return { code, messages };
640
+ }
641
+
642
+ function escapeTree(rows: [string, string][]) {
643
+ const root = makeTree();
644
+ mkdirSync(join(root, "docs"), { recursive: true });
645
+ writeFileSync(
646
+ join(root, "docs", "theme-propagation-spec.md"),
647
+ [
648
+ "# Theme",
649
+ "",
650
+ "## Size-recipe escapes",
651
+ "",
652
+ "| File | Reason |",
653
+ "| ---- | ------ |",
654
+ ...(rows.length ? rows : [["_(none)_", ""] as [string, string]]).map(
655
+ ([file, reason]) => `| \`${file}\` | \`${reason}\` |`,
656
+ ),
657
+ "",
658
+ ].join("\n"),
659
+ );
660
+ mkdirSync(join(root, "public", "forms", "src", "Toolbar"), { recursive: true });
661
+ return root;
662
+ }
663
+
664
+ const toolbar = "public/forms/src/Toolbar/index.tsx";
665
+
666
+ it("finds every escape form with its written reason, and ignores docs about the marker", () => {
667
+ const sites = findSizeRecipeEscapes(`/**
668
+ * Opt in with \`sizeRecipeEscape="reason"\` or \`// size-recipe-escape:\` above.
669
+ */
670
+ export const a = <Button height={40} sizeRecipeEscape="toolbar chrome match" />;
671
+ export const b = <Button height={40} sizeRecipeEscape={"toolbar chrome match"} />;
672
+ export const c = { paddingHorizontal: 0, sizeRecipeEscape: "LC-81 edges" };
673
+ export const d = sizeRecipeEscape(20, "hairline glyph");
674
+ export const e = sizeRecipeEscape(20);
675
+ export const f = <XStack
676
+ paddingHorizontal={0} // size-recipe-escape: square end-cap
677
+ />;
678
+ `);
679
+ expect(sites.map(({ line, form, reason }) => [line, form, reason])).toEqual([
680
+ [4, "prop", "toolbar chrome match"],
681
+ [5, "prop", "toolbar chrome match"],
682
+ [6, "prop", "LC-81 edges"],
683
+ [7, "call", "hairline glyph"],
684
+ [8, "call", undefined],
685
+ [10, "comment", "square end-cap"],
686
+ ]);
687
+ });
688
+
689
+ it("reads a JSX block-comment reason without its closer, and cannot read a reason held in a variable", () => {
690
+ const sites = findSizeRecipeEscapes(`export const a = (
691
+ <XStack>
692
+ {/* size-recipe-escape: square end-cap */}
693
+ <Button height={40} sizeRecipeEscape={reason} />
694
+ <Button height={40} sizeRecipeEscape={\`chrome \${n}\`} />
695
+ </XStack>
696
+ );
697
+ `);
698
+ expect(sites.map(({ line, form, reason }) => [line, form, reason])).toEqual([
699
+ [3, "comment", "square end-cap"],
700
+ [4, "prop", undefined],
701
+ [5, "prop", undefined],
702
+ ]);
703
+ });
704
+
705
+ it("fails an escape prop whose reason is not a literal, even when the file has a row", () => {
706
+ const root = escapeTree([[toolbar, "toolbar chrome match"]]);
707
+ writeFileSync(
708
+ join(root, toolbar),
709
+ [
710
+ 'const reason = "toolbar chrome match";',
711
+ "export const T = () => <Button height={40} sizeRecipeEscape={reason} />;",
712
+ 'export const U = () => <Button height={40} sizeRecipeEscape="toolbar chrome match" />;',
713
+ "",
714
+ ].join("\n"),
715
+ );
716
+ const { code, messages } = run(root);
717
+ expect(code).toBe(1);
718
+ expect(messages).toContain(`${toolbar}:2 — sizeRecipeEscape with no written reason`);
719
+ expect(messages).not.toContain(`${toolbar}:3`);
720
+ });
721
+
722
+ it("fails an escape whose file and reason are not a row, naming the file, line and reason", () => {
723
+ const root = escapeTree([]);
724
+ writeFileSync(
725
+ join(root, toolbar),
726
+ 'export const T = () => (\n <Button height={40} sizeRecipeEscape="toolbar chrome match" />\n);\n',
727
+ );
728
+ const { code, messages } = run(root);
729
+ expect(code).toBe(1);
730
+ expect(messages).toContain("SIZE-RECIPE ESCAPE REGISTRY");
731
+ expect(messages).toContain(`${toolbar}:2 — undeclared escape "toolbar chrome match"`);
732
+ });
733
+
734
+ it("fails a comment escape the same way, and one with no reason at all", () => {
735
+ const root = escapeTree([]);
736
+ writeFileSync(
737
+ join(root, toolbar),
738
+ "// size-recipe-escape: square end-cap\nexport const pad = 0;\nexport const h = sizeRecipeEscape(20);\n",
739
+ );
740
+ const { code, messages } = run(root);
741
+ expect(code).toBe(1);
742
+ expect(messages).toContain(`${toolbar}:1 — undeclared escape "square end-cap"`);
743
+ expect(messages).toContain(`${toolbar}:3 — sizeRecipeEscape with no written reason`);
744
+ });
745
+
746
+ it("fails a declared row whose escape is gone, so the table cannot rot into a blanket pass", () => {
747
+ const root = escapeTree([[toolbar, "toolbar chrome match"]]);
748
+ writeFileSync(join(root, toolbar), "export const T = () => null;\n");
749
+ const { code, messages } = run(root);
750
+ expect(code).toBe(1);
751
+ expect(messages).toContain(`${toolbar} — declared escape "toolbar chrome match" has no site`);
752
+ });
753
+
754
+ it("does not let a row for one file license the same reason in another", () => {
755
+ const root = escapeTree([[toolbar, "toolbar chrome match"]]);
756
+ writeFileSync(
757
+ join(root, toolbar),
758
+ 'export const T = () => <Button height={40} sizeRecipeEscape="toolbar chrome match" />;\n',
759
+ );
760
+ writeFileSync(
761
+ join(root, "features", "demo", "Bar.tsx"),
762
+ 'export const B = () => <Button height={40} sizeRecipeEscape="toolbar chrome match" />;\n',
763
+ );
764
+ const { code, messages } = run(root);
765
+ expect(code).toBe(1);
766
+ expect(messages).toContain(
767
+ 'features/demo/Bar.tsx:1 — undeclared escape "toolbar chrome match"',
768
+ );
769
+ expect(messages).not.toContain(`${toolbar}:1`);
770
+ });
771
+
772
+ it("passes every escape that is declared by file and exact reason, and ignores specs", () => {
773
+ const root = escapeTree([
774
+ [toolbar, "toolbar chrome match"],
775
+ [toolbar, "square end-cap"],
776
+ ]);
777
+ writeFileSync(
778
+ join(root, toolbar),
779
+ [
780
+ 'export const T = () => <Button height={40} sizeRecipeEscape="toolbar chrome match" />;',
781
+ "export const U = () => (",
782
+ " <XStack",
783
+ " paddingHorizontal={0} // size-recipe-escape: square end-cap",
784
+ " />",
785
+ ");",
786
+ "",
787
+ ].join("\n"),
788
+ );
789
+ writeFileSync(
790
+ join(root, "public", "forms", "src", "Toolbar", "index.spec.tsx"),
791
+ 'export const S = <Button height={40} sizeRecipeEscape="only in a spec" />;\n',
792
+ );
793
+ expect(run(root).code).toBe(0);
794
+ });
795
+
796
+ it("skips the check when the tree has no escape table, the same as the drawing registry", () => {
797
+ const root = makeTree();
798
+ writeFileSync(
799
+ join(root, "features", "demo", "Bar.tsx"),
800
+ 'export const B = () => <Button height={40} sizeRecipeEscape="anything" />;\n',
801
+ );
802
+ expect(run(root).code).toBe(0);
803
+ });
804
+ });
805
+
806
+ describe("colour-literal escape registry (MPO-326)", () => {
807
+ function run(root: string) {
808
+ const error = vi.spyOn(console, "error").mockImplementation(() => {});
809
+ const log = vi.spyOn(console, "log").mockImplementation(() => {});
810
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
811
+ const code = runConventionChecks({ roots: { root } });
812
+ const messages = error.mock.calls.map((call) => String(call[0])).join("\n");
813
+ error.mockRestore();
814
+ log.mockRestore();
815
+ warn.mockRestore();
816
+ return { code, messages };
817
+ }
818
+
819
+ function escapeTree(rows: [string, string][]) {
820
+ const root = makeTree();
821
+ mkdirSync(join(root, "docs"), { recursive: true });
822
+ writeFileSync(
823
+ join(root, "docs", "theme-propagation-spec.md"),
824
+ [
825
+ "# Theme",
826
+ "",
827
+ "## Colour-literal escapes",
828
+ "",
829
+ "| File | Reason |",
830
+ "| ---- | ------ |",
831
+ ...(rows.length ? rows : [["_(none)_", ""] as [string, string]]).map(
832
+ ([file, reason]) => `| \`${file}\` | \`${reason}\` |`,
833
+ ),
834
+ "",
835
+ ].join("\n"),
836
+ );
837
+ mkdirSync(join(root, "apps", "demo", "gnome"), { recursive: true });
838
+ return root;
839
+ }
840
+
841
+ const paint = "apps/demo/gnome/gtk-paint.ts";
842
+ const reason = "GTK widget paint outside TamaguiProvider";
843
+
844
+ it("fails a hex-escape whose file and reason are not a row, naming the file, line and reason", () => {
845
+ const root = escapeTree([]);
846
+ writeFileSync(join(root, paint), `// hex-escape: ${reason}\nexport const bg = "#ffffff";\n`);
847
+ const { code, messages } = run(root);
848
+ expect(code).toBe(1);
849
+ expect(messages).toContain("COLOUR-LITERAL ESCAPE REGISTRY");
850
+ expect(messages).toContain(`${paint}:1 — undeclared escape "${reason}"`);
851
+ });
852
+
853
+ it("fails a hex-escape with no reason", () => {
854
+ const root = escapeTree([]);
855
+ writeFileSync(join(root, paint), '// hex-escape:\nexport const bg = "#ffffff";\n');
856
+ const { code, messages } = run(root);
857
+ expect(code).toBe(1);
858
+ expect(messages).toContain(`${paint}:1 — hex-escape with no written reason`);
859
+ });
860
+
861
+ it("fails a declared row whose comment is gone", () => {
862
+ const root = escapeTree([[paint, reason]]);
863
+ writeFileSync(join(root, paint), "export const bg = 1;\n");
864
+ const { code, messages } = run(root);
865
+ expect(code).toBe(1);
866
+ expect(messages).toContain(`${paint} — declared escape "${reason}" has no comment in the code`);
867
+ });
868
+
869
+ it("does not let a row for one file license the same reason in another", () => {
870
+ const root = escapeTree([[paint, reason]]);
871
+ writeFileSync(join(root, paint), `// hex-escape: ${reason}\n`);
872
+ writeFileSync(join(root, "apps", "demo", "Other.tsx"), `// hex-escape: ${reason}\n`);
873
+ const { code, messages } = run(root);
874
+ expect(code).toBe(1);
875
+ expect(messages).toContain(`apps/demo/Other.tsx:1 — undeclared escape "${reason}"`);
876
+ expect(messages).not.toContain(`${paint}:1`);
877
+ });
878
+
879
+ it("passes a declared escape, and skips the check when the tree has no escape table", () => {
880
+ const root = escapeTree([[paint, reason]]);
881
+ writeFileSync(join(root, paint), `// hex-escape: ${reason}\nexport const bg = "#ffffff";\n`);
882
+ expect(run(root).code).toBe(0);
883
+
884
+ const bare = makeTree();
885
+ writeFileSync(join(bare, "apps", "demo", "Any.tsx"), "// hex-escape: anything\n");
886
+ expect(run(bare).code).toBe(0);
887
+ });
888
+ });
889
+
890
+ describe("LC-24 enter/exit driver (MPO-327)", () => {
891
+ function run(root: string) {
892
+ const error = vi.spyOn(console, "error").mockImplementation(() => {});
893
+ const log = vi.spyOn(console, "log").mockImplementation(() => {});
894
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
895
+ const code = runConventionChecks({ roots: { root } });
896
+ const errors = error.mock.calls.map((c) => String(c[0])).join("\n");
897
+ error.mockRestore();
898
+ log.mockRestore();
899
+ warn.mockRestore();
900
+ return { code, errors };
901
+ }
902
+
903
+ it("fails an enterStyle with no transition on the same element", () => {
904
+ const root = makeTree();
905
+ writeFileSync(
906
+ join(root, "apps", "demo", "App.tsx"),
907
+ "export const App = () => <View enterStyle={{ opacity: 0 }} />;\n",
908
+ );
909
+ const { code, errors } = run(root);
910
+ expect(code).toBe(1);
911
+ expect(errors).toMatch(/apps\/demo\/App\.tsx:1 — enter\/exitStyle with no `transition` driver/);
912
+ });
913
+
914
+ it("accepts the transitionProps spread as the driver", () => {
915
+ const root = makeTree();
916
+ writeFileSync(
917
+ join(root, "apps", "demo", "App.tsx"),
918
+ "export const App = () => <View enterStyle={{ opacity: 0 }} {...transitionProps(knobProps.transition)} />;\n",
919
+ );
920
+ const { code, errors } = run(root);
921
+ expect(errors).not.toMatch(/LC-24/);
922
+ expect(code).toBe(0);
923
+ });
924
+ });
925
+
926
+ describe("foreign control specimen in stories (LC-56 STORY-HONESTY / MPO-316)", () => {
927
+ function run(root: string) {
928
+ const error = vi.spyOn(console, "error").mockImplementation(() => {});
929
+ const log = vi.spyOn(console, "log").mockImplementation(() => {});
930
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
931
+ const code = runConventionChecks({ roots: { root } });
932
+ const messages = error.mock.calls.map((call) => String(call[0])).join("\n");
933
+ error.mockRestore();
934
+ log.mockRestore();
935
+ warn.mockRestore();
936
+ return { code, messages };
937
+ }
938
+
939
+ function story(root: string, rel: string, body: string) {
940
+ const full = join(root, rel);
941
+ mkdirSync(join(full, ".."), { recursive: true });
942
+ writeFileSync(full, body);
943
+ return rel;
944
+ }
945
+
946
+ const csfTail = `
947
+ const meta = { title: "Forms/Demo" };
948
+ export default meta;
949
+ export const Basic = { render: () => null };
950
+ `;
951
+
952
+ it("fails a story mounting `Input as TamaguiInput` from tamagui, naming file and line", () => {
953
+ const root = makeTree();
954
+ const file = story(
955
+ root,
956
+ "public/forms/src/ErrorSummary.stories.tsx",
957
+ `import type { Meta } from "@storybook/react";
958
+ import { Input as TamaguiInput, Paragraph, YStack } from "tamagui";
959
+ ${csfTail}`,
960
+ );
961
+ const { code, messages } = run(root);
962
+ expect(code).toBe(1);
963
+ expect(messages).toContain("STORY-HONESTY");
964
+ expect(messages).toContain(`${file}:2 — \`Input as TamaguiInput\` from "tamagui"`);
965
+ });
966
+
967
+ it("passes once the story carries parameters.foreignContrastSpecimen: true", () => {
968
+ const root = makeTree();
969
+ story(
970
+ root,
971
+ "public/forms/src/fields/Input/Input.stories.tsx",
972
+ `import { Input as TamaguiInput, XStack } from "tamagui";
973
+ const meta = { title: "Forms/Input" };
974
+ export default meta;
975
+ export const Comparison = {
976
+ parameters: { foreignContrastSpecimen: true },
977
+ render: () => null,
978
+ };
979
+ `,
980
+ );
981
+ expect(run(root).code).toBe(0);
982
+ });
983
+
984
+ it("ignores layout and text primitives, type-only imports and default imports", () => {
985
+ const root = makeTree();
986
+ story(
987
+ root,
988
+ "public/forms/src/FormGrid.stories.tsx",
989
+ `import { Paragraph, XStack, YStack, Text, View, SizableText, Label } from "tamagui";
990
+ import type { Input, Button } from "tamagui";
991
+ import tamagui from "tamagui";
992
+ import { Input } from "./fields/Input";
993
+ ${csfTail}`,
994
+ );
995
+ expect(run(root).code).toBe(0);
996
+ });
997
+
998
+ it("flags every control in a multi-line import, in any catalog package", () => {
999
+ const root = makeTree();
1000
+ const forms = story(
1001
+ root,
1002
+ "public/forms/src/Demo.stories.tsx",
1003
+ `import {
1004
+ Button,
1005
+ Checkbox as TamaguiCheckbox,
1006
+ YStack,
1007
+ } from "tamagui";
1008
+ ${csfTail}`,
1009
+ );
1010
+ const graph = story(
1011
+ root,
1012
+ "public/node-graph/src/nodes/Port.stories.tsx",
1013
+ `import { Label, Switch, XStack } from "tamagui";
1014
+ ${csfTail}`,
1015
+ );
1016
+ const { code, messages } = run(root);
1017
+ expect(code).toBe(1);
1018
+ expect(messages).toContain(`${forms}:1 — \`Button\` from "tamagui"`);
1019
+ expect(messages).toContain(`${forms}:1 — \`Checkbox as TamaguiCheckbox\` from "tamagui"`);
1020
+ expect(messages).toContain(`${graph}:1 — \`Switch\` from "tamagui"`);
1021
+ });
1022
+
1023
+ it("leaves the frappe data client's hook demos alone, since it is not a catalog package", () => {
1024
+ const root = makeTree();
1025
+ story(
1026
+ root,
1027
+ "public/frappe/src/useFrappeCollection.stories.tsx",
1028
+ `import { Button, Text, YStack } from "tamagui";
1029
+ ${csfTail}`,
1030
+ );
1031
+ expect(run(root).code).toBe(0);
1032
+ });
1033
+
1034
+ it("does not let an earlier import's names leak into the tamagui clause", () => {
1035
+ const root = makeTree();
1036
+ story(
1037
+ root,
1038
+ "public/forms/src/Leak.stories.tsx",
1039
+ `import { Button } from "./Button";
1040
+ import { YStack } from "tamagui";
1041
+ ${csfTail}`,
1042
+ );
1043
+ expect(run(root).code).toBe(0);
1044
+ });
1045
+ });
@@ -6,6 +6,8 @@ import {
6
6
  CHROME_PROPS,
7
7
  GEOMETRY_PROPS,
8
8
  LAYOUT_PROPS,
9
+ MARGIN_PROPS,
10
+ findHexEscapes,
9
11
  isPaletteModule,
10
12
  matchColorLiteral,
11
13
  plugin,
@@ -314,3 +316,173 @@ describe("no-hex-literals comments and palette (MPO-17 AC-4)", () => {
314
316
  expect(reports.map((r) => r.data?.value ?? r.messageId)).toEqual(["rgba(0,0,0,0.35)"]);
315
317
  });
316
318
  });
319
+
320
+ describe("no-hex-literals hex-escape comment (MPO-326)", () => {
321
+ function reportsFor(filename: string, text: string) {
322
+ const reports: Report[] = [];
323
+ const rule = plugin.rules?.["no-hex-literals"] as {
324
+ create: (c: unknown) => { Literal?: (n: unknown) => void };
325
+ };
326
+ const visitors = rule.create({
327
+ filename,
328
+ sourceCode: { text },
329
+ report: (report: Report) => reports.push(report),
330
+ });
331
+ visitors.Literal?.({ type: "Literal", value: "#ffffff" });
332
+ return reports.map((r) => r.data?.value);
333
+ }
334
+
335
+ it("errors on a hex in apps/ when the file carries no hex-escape comment", () => {
336
+ expect(reportsFor("apps/one/gnome/main.tsx", 'export const bg = "#ffffff";\n')).toEqual([
337
+ "#ffffff",
338
+ ]);
339
+ });
340
+
341
+ it("skips the file whose line comment writes a reason", () => {
342
+ const text =
343
+ '// hex-escape: GTK widget paint outside TamaguiProvider\nexport const bg = "#ffffff";\n';
344
+ expect(reportsFor("apps/one/gnome/gtk-paint.ts", text)).toEqual([]);
345
+ });
346
+
347
+ it("does not take a comment with no reason, a JSDoc line or a backticked mention as the marker", () => {
348
+ for (const text of [
349
+ '// hex-escape:\nexport const bg = "#ffffff";\n',
350
+ '/**\n * hex-escape: in prose\n */\nexport const bg = "#ffffff";\n',
351
+ '// opt out with `// hex-escape: <reason>`\nexport const bg = "#ffffff";\n',
352
+ ]) {
353
+ expect(reportsFor("apps/one/spa/main.tsx", text)).toEqual(["#ffffff"]);
354
+ }
355
+ });
356
+
357
+ it("finds line and block comment markers with their reasons", () => {
358
+ expect(
359
+ findHexEscapes(
360
+ [
361
+ "// hex-escape: content script on third-party pages",
362
+ "const a = 1;",
363
+ "{/* hex-escape: Storybook manager panel */}",
364
+ " * hex-escape: JSDoc prose",
365
+ "// hex-escape:",
366
+ ].join("\n"),
367
+ ),
368
+ ).toEqual([
369
+ { line: 1, reason: "content script on third-party pages" },
370
+ { line: 3, reason: "Storybook manager panel" },
371
+ { line: 5, reason: "" },
372
+ ]);
373
+ });
374
+ });
375
+
376
+ /**
377
+ * `00` L9a behavioural teeth (MPO-23). The rule counts JSX depth on enter and
378
+ * exit, so the driver walks a small tree the way the linter does: enter a
379
+ * node, visit its children, then fire `<type>:exit`.
380
+ */
381
+ describe("mpo-conventions/no-child-margin (00 L9a)", () => {
382
+ type Node = { type: string; [key: string]: unknown };
383
+ type Visitors = Record<string, ((node: Node) => void) | undefined>;
384
+
385
+ function element(name: string, attrs: { prop: string; value: unknown }[], children: Node[] = []) {
386
+ return {
387
+ type: "JSXElement",
388
+ openingElement: jsxOpen(name, attrs),
389
+ children,
390
+ };
391
+ }
392
+
393
+ function fragment(children: Node[]) {
394
+ return { type: "JSXFragment", children };
395
+ }
396
+
397
+ function walk(visitors: Visitors, node: Node) {
398
+ visitors[node.type]?.(node);
399
+ if (node.type === "JSXElement") {
400
+ walk(visitors, node.openingElement as Node);
401
+ for (const child of node.children as Node[]) walk(visitors, child);
402
+ }
403
+ if (node.type === "JSXFragment") {
404
+ for (const child of node.children as Node[]) walk(visitors, child);
405
+ }
406
+ visitors[`${node.type}:exit`]?.(node);
407
+ }
408
+
409
+ function lint(tree: Node, options?: { slotMargins?: string[] }) {
410
+ const reports: Report[] = [];
411
+ const rule = plugin.rules?.["no-child-margin"] as unknown as {
412
+ create: (context: unknown) => Visitors;
413
+ };
414
+ walk(
415
+ rule.create({
416
+ filename: "features/demo/screen.tsx",
417
+ options: [options ?? { slotMargins: [] }],
418
+ report: (report: Report) => reports.push(report),
419
+ }),
420
+ tree,
421
+ );
422
+ return reports.map((r) => `${r.data?.name}.${r.data?.prop}`);
423
+ }
424
+
425
+ it("covers every long margin prop and every Tamagui shorthand for one", () => {
426
+ expect(MARGIN_PROPS).toEqual(
427
+ expect.arrayContaining(["margin", "marginTop", "marginHorizontal", "m", "mt", "mx", "my"]),
428
+ );
429
+ expect(plugin.rules?.["no-child-margin"]).toBeDefined();
430
+ });
431
+
432
+ it("errors on a margin a child sets inside its parent, token or number, long or short", () => {
433
+ const tree = element(
434
+ "YStack",
435
+ [{ prop: "gap", value: token("$3") }],
436
+ [
437
+ element("Button", [{ prop: "marginTop", value: token("$2") }]),
438
+ element("Paragraph", [{ prop: "mb", value: numeric(8) }]),
439
+ element("Separator", [{ prop: "marginHorizontal", value: token("$-4") }]),
440
+ element("XStack", [{ prop: "marginLeft", value: token("auto") }]),
441
+ ],
442
+ );
443
+ expect(lint(tree)).toEqual([
444
+ "Button.marginTop",
445
+ "Paragraph.mb",
446
+ "Separator.marginHorizontal",
447
+ "XStack.marginLeft",
448
+ ]);
449
+ });
450
+
451
+ it("errors on a margin under a fragment too: a fragment's children are still siblings", () => {
452
+ const tree = fragment([element("Card", [{ prop: "margin", value: token("$4") }])]);
453
+ expect(lint(tree)).toEqual(["Card.margin"]);
454
+ });
455
+
456
+ it("leaves the outermost element alone and never flags a zero-reset (00 L8 E1)", () => {
457
+ const tree = element(
458
+ "Screen",
459
+ [{ prop: "marginTop", value: token("$4") }],
460
+ [
461
+ element("H2", [{ prop: "margin", value: numeric(0) }]),
462
+ element("Paragraph", [{ prop: "marginVertical", value: token("$0") }]),
463
+ element("Paragraph", [{ prop: "marginBottom", value: token("0") }]),
464
+ element("XStack", [
465
+ { prop: "gap", value: token("$2") },
466
+ { prop: "flex", value: numeric(1) },
467
+ ]),
468
+ ],
469
+ );
470
+ expect(lint(tree)).toEqual([]);
471
+ });
472
+
473
+ it("stays silent on an element the spec declares a slot, by full or bare name", () => {
474
+ const tree = element(
475
+ "Card",
476
+ [],
477
+ [
478
+ element("Card.Footer", [{ prop: "marginTop", value: token("auto") }]),
479
+ element("Header", [{ prop: "marginBottom", value: token("auto") }]),
480
+ ],
481
+ );
482
+ expect(lint(tree, { slotMargins: ["Card.Footer", "Header"] })).toEqual([]);
483
+ expect(lint(tree, { slotMargins: [] })).toEqual([
484
+ "Card.Footer.marginTop",
485
+ "Header.marginBottom",
486
+ ]);
487
+ });
488
+ });