td-ai-tools 1.2.11 → 1.3.2

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.
Files changed (41) hide show
  1. package/package.json +1 -1
  2. package/skills/README.md +2 -2
  3. package/skills/shopify-lint/SKILL.md +26 -18
  4. package/skills/shopify-lint/eslint-plugin-theory/README.md +25 -8
  5. package/skills/shopify-lint/eslint-plugin-theory/src/liquid/mask.test.ts +41 -0
  6. package/skills/shopify-lint/eslint-plugin-theory/src/liquid/mask.ts +41 -2
  7. package/skills/shopify-lint/hooks/claude-stop.sh +2 -3
  8. package/skills/shopify-lint/hooks/codex-stop.sh +2 -3
  9. package/skills/shopify-lint/scripts/__pycache__/shopify_lint.cpython-312.pyc +0 -0
  10. package/skills/shopify-lint/scripts/setup.sh +21 -2
  11. package/skills/shopify-lint/scripts/shopify_lint.py +179 -30
  12. package/skills/shopify-lint/stylelint-config-theory/.stylelintignore +9 -0
  13. package/skills/shopify-lint/stylelint-config-theory/README.md +144 -0
  14. package/skills/shopify-lint/stylelint-config-theory/bin/lint-css.mjs +206 -0
  15. package/skills/shopify-lint/stylelint-config-theory/package-lock.json +3110 -0
  16. package/skills/shopify-lint/stylelint-config-theory/package.json +47 -0
  17. package/skills/shopify-lint/stylelint-config-theory/src/build.test.ts +31 -0
  18. package/skills/shopify-lint/stylelint-config-theory/src/config.test.ts +150 -0
  19. package/skills/shopify-lint/stylelint-config-theory/src/config.ts +109 -0
  20. package/skills/shopify-lint/stylelint-config-theory/src/index.ts +15 -0
  21. package/skills/shopify-lint/stylelint-config-theory/src/integration.test.ts +187 -0
  22. package/skills/shopify-lint/stylelint-config-theory/src/liquid/mask.test.ts +300 -0
  23. package/skills/shopify-lint/stylelint-config-theory/src/liquid/mask.ts +203 -0
  24. package/skills/shopify-lint/stylelint-config-theory/src/types/shared-configs.d.ts +26 -0
  25. package/skills/shopify-lint/stylelint-config-theory/tsconfig.build.json +9 -0
  26. package/skills/shopify-lint/stylelint-config-theory/tsconfig.json +14 -0
  27. package/skills/shopify-lint/stylelint-config-theory/vitest.config.ts +11 -0
  28. package/skills/shopify-lint/tests/__pycache__/test_shopify_lint.cpython-312.pyc +0 -0
  29. package/skills/shopify-lint/tests/test_shopify_lint.py +483 -8
  30. package/skills/shopify-lint/theme-check-theory/.theme-check.example.yml +7 -0
  31. package/skills/shopify-lint/theme-check-theory/README.md +48 -0
  32. package/skills/shopify-pre-pr/SKILL.md +129 -0
  33. package/skills/shopify-pre-pr/agents/openai.yaml +4 -0
  34. package/skills/shopify-pre-pr/references/td-change-delimiters.md +88 -0
  35. package/skills/shopify-pre-pr/scripts/__pycache__/test_vendor_change_audit.cpython-312.pyc +0 -0
  36. package/skills/shopify-pre-pr/scripts/__pycache__/vendor_change_audit.cpython-312.pyc +0 -0
  37. package/skills/shopify-pre-pr/scripts/test_vendor_change_audit.py +424 -0
  38. package/skills/shopify-pre-pr/scripts/vendor_change_audit.py +540 -0
  39. package/skills/record-changes/SKILL.md +0 -76
  40. package/skills/record-changes/agents/openai.yaml +0 -4
  41. /package/skills/{record-changes → shopify-pre-pr}/scripts/branch_diff_context.py +0 -0
@@ -135,14 +135,14 @@ class ThemeCheckTests(unittest.TestCase):
135
135
  def test_advisory_offenses_never_fail_at_the_level_the_hooks_use(self) -> None:
136
136
  """The Stop hooks run at --fail-level warning; info must stay advisory.
137
137
 
138
- `info` is reserved for JavaScript parse errors inside Liquid, which
139
- masking cannot always avoid. If those blocked handoff, a theme could be
140
- wedged in a fix loop it has no way out of.
138
+ Nothing this skill reports is graded `info` today — Theme Check can be
139
+ configured to, and the level exists for a check whose findings should
140
+ surface without ever blocking handoff.
141
141
  """
142
142
  info_report = [
143
143
  {
144
144
  "path": "changed.liquid",
145
- "offenses": [{"check": "eslint/parse-error", "severity": "info"}],
145
+ "offenses": [{"check": "SomeAdvisoryCheck", "severity": "info"}],
146
146
  }
147
147
  ]
148
148
 
@@ -439,7 +439,14 @@ class EslintNormalizationTests(unittest.TestCase):
439
439
  self.assertEqual(offense["severity"], "error")
440
440
  self.assertNotIn("masked", offense["message"])
441
441
 
442
- def test_parse_error_inside_liquid_is_advisory(self) -> None:
442
+ def test_parse_error_inside_liquid_is_an_error_too(self) -> None:
443
+ """A `{% javascript %}` parse error is graded like any other.
444
+
445
+ It used to be demoted to `info`, because Liquid that straddles a
446
+ JavaScript syntax boundary cannot be masked into parseable code. Liquid
447
+ in the block is now reported by `StaticStylesheetAndJavascriptTags`, so the
448
+ fix is well defined and the parse error blocks like the rest.
449
+ """
443
450
  results = [
444
451
  {
445
452
  "filePath": "/repo/sections/td-hero.liquid",
@@ -459,12 +466,397 @@ class EslintNormalizationTests(unittest.TestCase):
459
466
  [report] = shopify_lint.normalize_eslint_results(results)
460
467
  offense = report["offenses"][0]
461
468
 
462
- self.assertEqual(offense["severity"], "info")
463
- self.assertIn("masked before parsing", offense["message"])
464
- self.assertEqual(shopify_lint.exit_code_for([report], "error"), 0)
469
+ self.assertEqual(offense["check"], "eslint/parse-error")
470
+ self.assertEqual(offense["severity"], "error")
471
+ self.assertEqual(offense["message"], "Parsing error: Unexpected token")
472
+ self.assertEqual(shopify_lint.exit_code_for([report], "error"), 1)
473
+
474
+
475
+ class CssTargetTests(unittest.TestCase):
476
+ def build_theme(self, root: Path, names: tuple[str, ...]) -> None:
477
+ for name in names:
478
+ path = root / name
479
+ path.parent.mkdir(parents=True, exist_ok=True)
480
+ path.write_text("content\n")
481
+
482
+ def test_selects_css_and_liquid_files_only(self) -> None:
483
+ with tempfile.TemporaryDirectory() as directory:
484
+ root = Path(directory)
485
+ names = (
486
+ "assets/component-td-card.css",
487
+ "sections/td-hero.liquid",
488
+ "assets/td-app.js",
489
+ "config/settings_schema.json",
490
+ )
491
+ self.build_theme(root, names)
492
+
493
+ targets = shopify_lint.select_css_targets(set(names), root, root)
494
+
495
+ self.assertEqual(
496
+ [target.relative_to(root).as_posix() for target in targets],
497
+ ["assets/component-td-card.css", "sections/td-hero.liquid"],
498
+ )
499
+
500
+ def test_skips_css_without_the_theory_prefix(self) -> None:
501
+ with tempfile.TemporaryDirectory() as directory:
502
+ root = Path(directory)
503
+ names = (
504
+ "assets/component-td-card.css",
505
+ "assets/vendor.css",
506
+ "assets/theme.css",
507
+ )
508
+ self.build_theme(root, names)
509
+
510
+ targets = shopify_lint.select_css_targets(set(names), root, root)
511
+
512
+ self.assertEqual(
513
+ [target.relative_to(root).as_posix() for target in targets],
514
+ ["assets/component-td-card.css"],
515
+ )
516
+
517
+ def test_prefix_match_is_case_insensitive_and_positional(self) -> None:
518
+ with tempfile.TemporaryDirectory() as directory:
519
+ root = Path(directory)
520
+ names = (
521
+ "assets/TD-Hero.css",
522
+ "assets/section-td-banner.css",
523
+ "assets/tdcard.css",
524
+ "assets/standard.css",
525
+ )
526
+ self.build_theme(root, names)
527
+
528
+ targets = shopify_lint.select_css_targets(set(names), root, root)
529
+
530
+ self.assertEqual(
531
+ [target.relative_to(root).as_posix() for target in targets],
532
+ ["assets/TD-Hero.css", "assets/section-td-banner.css"],
533
+ )
534
+
535
+ def test_prefix_is_matched_on_the_filename_not_the_directory(self) -> None:
536
+ with tempfile.TemporaryDirectory() as directory:
537
+ root = Path(directory)
538
+ names = ("td-components/vendor.css", "assets/component-td-card.css")
539
+ self.build_theme(root, names)
540
+
541
+ targets = shopify_lint.select_css_targets(set(names), root, root)
542
+
543
+ self.assertEqual(
544
+ [target.relative_to(root).as_posix() for target in targets],
545
+ ["assets/component-td-card.css"],
546
+ )
547
+
548
+ def test_liquid_is_linted_regardless_of_its_name(self) -> None:
549
+ with tempfile.TemporaryDirectory() as directory:
550
+ root = Path(directory)
551
+ names = ("sections/main-product.liquid", "snippets/vendor-widget.liquid")
552
+ self.build_theme(root, names)
553
+
554
+ targets = shopify_lint.select_css_targets(set(names), root, root)
555
+
556
+ self.assertEqual(
557
+ [target.relative_to(root).as_posix() for target in targets],
558
+ ["sections/main-product.liquid", "snippets/vendor-widget.liquid"],
559
+ )
560
+
561
+ def test_skips_minified_bundles_even_with_the_theory_prefix(self) -> None:
562
+ with tempfile.TemporaryDirectory() as directory:
563
+ root = Path(directory)
564
+ names = ("assets/td-vendor.min.css", "assets/component-td-card.css")
565
+ self.build_theme(root, names)
566
+
567
+ targets = shopify_lint.select_css_targets(set(names), root, root)
568
+
569
+ self.assertEqual(
570
+ [target.relative_to(root).as_posix() for target in targets],
571
+ ["assets/component-td-card.css"],
572
+ )
573
+
574
+ def test_skips_paths_that_no_longer_exist(self) -> None:
575
+ with tempfile.TemporaryDirectory() as directory:
576
+ root = Path(directory)
577
+ self.build_theme(root, ("assets/td-kept.css",))
578
+
579
+ targets = shopify_lint.select_css_targets(
580
+ {"assets/td-kept.css", "assets/td-deleted.css"}, root, root
581
+ )
582
+
583
+ self.assertEqual(
584
+ [target.relative_to(root).as_posix() for target in targets],
585
+ ["assets/td-kept.css"],
586
+ )
587
+
588
+ def test_skips_paths_outside_the_theme_root(self) -> None:
589
+ with tempfile.TemporaryDirectory() as directory:
590
+ root = Path(directory)
591
+ # Both carry the Theory prefix, so only the theme-root check can
592
+ # be what excludes the second one.
593
+ self.build_theme(root, ("theme/assets/td-app.css", "tooling/td-docs.css"))
594
+
595
+ targets = shopify_lint.select_css_targets(
596
+ {"theme/assets/td-app.css", "tooling/td-docs.css"}, root, root / "theme"
597
+ )
598
+
599
+ self.assertEqual(
600
+ [target.relative_to(root).as_posix() for target in targets],
601
+ ["theme/assets/td-app.css"],
602
+ )
603
+
604
+
605
+ class CssLintCommandTests(unittest.TestCase):
606
+ def test_does_not_spawn_node_when_there_are_no_targets(self) -> None:
607
+ with patch.object(shopify_lint.subprocess, "run") as run:
608
+ result = shopify_lint.run_css_lint(Path("/theme"), [])
609
+
610
+ self.assertEqual(result, [])
611
+ run.assert_not_called()
612
+
613
+ def test_runs_the_bundled_runner_with_a_json_payload(self) -> None:
614
+ completed = subprocess.CompletedProcess([], 0, stdout="[]", stderr="")
615
+ runner = Path("/skill/stylelint-config-theory/bin/lint-css.mjs")
616
+
617
+ with patch.object(shopify_lint.Path, "is_file", return_value=True):
618
+ with patch.object(
619
+ shopify_lint.subprocess, "run", return_value=completed
620
+ ) as run:
621
+ result = shopify_lint.run_css_lint(
622
+ Path("/theme"), [Path("/theme/assets/td-app.css")], runner
623
+ )
624
+
625
+ self.assertEqual(result, [])
626
+ run.assert_called_once_with(
627
+ ["node", str(runner)],
628
+ input=json.dumps(
629
+ {"cwd": "/theme", "files": ["/theme/assets/td-app.css"]}
630
+ ),
631
+ cwd=Path("/theme"),
632
+ capture_output=True,
633
+ text=True,
634
+ check=False,
635
+ )
636
+
637
+ def test_missing_runner_asks_for_setup(self) -> None:
638
+ with self.assertRaises(shopify_lint.ShopifyLintError) as error:
639
+ shopify_lint.run_css_lint(
640
+ Path("/theme"),
641
+ [Path("/theme/assets/td-app.css")],
642
+ Path("/nowhere/lint-css.mjs"),
643
+ )
644
+
645
+ self.assertIn("setup.sh", str(error.exception))
646
+
647
+ def test_missing_node_is_reported(self) -> None:
648
+ with patch.object(shopify_lint.Path, "is_file", return_value=True):
649
+ with patch.object(
650
+ shopify_lint.subprocess, "run", side_effect=FileNotFoundError
651
+ ):
652
+ with self.assertRaises(shopify_lint.ShopifyLintError) as error:
653
+ shopify_lint.run_css_lint(
654
+ Path("/theme"), [Path("/theme/assets/td-app.css")]
655
+ )
656
+
657
+ self.assertIn("Node.js", str(error.exception))
658
+
659
+ def test_runner_failure_is_reported(self) -> None:
660
+ completed = subprocess.CompletedProcess(
661
+ [], 1, stdout="", stderr="theory css lint: config is invalid"
662
+ )
663
+
664
+ with patch.object(shopify_lint.Path, "is_file", return_value=True):
665
+ with patch.object(shopify_lint.subprocess, "run", return_value=completed):
666
+ with self.assertRaises(shopify_lint.ShopifyLintError) as error:
667
+ shopify_lint.run_css_lint(
668
+ Path("/theme"), [Path("/theme/assets/td-app.css")]
669
+ )
670
+
671
+ self.assertIn("config is invalid", str(error.exception))
672
+
673
+ def test_non_json_output_is_reported(self) -> None:
674
+ completed = subprocess.CompletedProcess([], 0, stdout="not json", stderr="")
675
+
676
+ with patch.object(shopify_lint.Path, "is_file", return_value=True):
677
+ with patch.object(shopify_lint.subprocess, "run", return_value=completed):
678
+ with self.assertRaises(shopify_lint.ShopifyLintError) as error:
679
+ shopify_lint.run_css_lint(
680
+ Path("/theme"), [Path("/theme/assets/td-app.css")]
681
+ )
682
+
683
+ self.assertIn("Theory CSS lint failed", str(error.exception))
684
+
685
+
686
+ class StylelintNormalizationTests(unittest.TestCase):
687
+ def test_namespaces_rules_and_zero_indexes_positions(self) -> None:
688
+ results = [
689
+ {
690
+ "path": "/theme/assets/component-td-card.css",
691
+ "warnings": [
692
+ {
693
+ "line": 4,
694
+ "column": 3,
695
+ "rule": "length-zero-no-unit",
696
+ "severity": "warning",
697
+ "text": "Disallowed unit (length-zero-no-unit)",
698
+ }
699
+ ],
700
+ }
701
+ ]
702
+
703
+ [report] = shopify_lint.normalize_stylelint_results(results)
704
+ offense = report["offenses"][0]
705
+
706
+ self.assertEqual(offense["check"], "stylelint/length-zero-no-unit")
707
+ self.assertEqual(offense["severity"], "warning")
708
+ self.assertEqual(offense["start_row"], 3)
709
+ self.assertEqual(offense["start_column"], 2)
710
+
711
+ def test_keeps_a_namespaced_plugin_rule_as_is(self) -> None:
712
+ results = [
713
+ {
714
+ "path": "/theme/assets/component-td-card.css",
715
+ "warnings": [
716
+ {"line": 1, "column": 1, "rule": "theory/no-raw-colors", "severity": "error"}
717
+ ],
718
+ }
719
+ ]
720
+
721
+ [report] = shopify_lint.normalize_stylelint_results(results)
722
+
723
+ self.assertEqual(report["offenses"][0]["check"], "theory/no-raw-colors")
724
+
725
+ def test_drops_files_without_warnings(self) -> None:
726
+ results = [{"path": "/theme/assets/component-td-card.css", "warnings": []}]
727
+
728
+ self.assertEqual(shopify_lint.normalize_stylelint_results(results), [])
729
+
730
+ def test_css_parse_error_is_an_error(self) -> None:
731
+ results = [
732
+ {
733
+ "path": "/theme/assets/component-td-card.css",
734
+ "warnings": [
735
+ {
736
+ "line": 1,
737
+ "column": 1,
738
+ "rule": "CssSyntaxError",
739
+ "severity": "error",
740
+ "text": "Unclosed block (CssSyntaxError)",
741
+ }
742
+ ],
743
+ }
744
+ ]
745
+
746
+ [report] = shopify_lint.normalize_stylelint_results(results)
747
+ offense = report["offenses"][0]
748
+
749
+ self.assertEqual(offense["check"], "stylelint/parse-error")
750
+ self.assertEqual(offense["severity"], "error")
751
+ self.assertNotIn("masked before parsing", offense["message"])
752
+
753
+ def test_liquid_parse_error_is_an_error_too(self) -> None:
754
+ """A `{% stylesheet %}` parse error is graded like any other.
755
+
756
+ It used to be demoted to `info` because masking Liquid out of the block
757
+ cannot always produce parseable CSS. Liquid in the block is now reported
758
+ by `StaticStylesheetAndJavascriptTags`, so the fix is well defined.
759
+ """
760
+ results = [
761
+ {
762
+ "path": "/theme/sections/td-hero.liquid",
763
+ "warnings": [
764
+ {
765
+ "line": 3,
766
+ "column": 1,
767
+ "rule": "CssSyntaxError",
768
+ "severity": "error",
769
+ "text": "Unclosed block (CssSyntaxError)",
770
+ }
771
+ ],
772
+ }
773
+ ]
774
+
775
+ [report] = shopify_lint.normalize_stylelint_results(results)
776
+ offense = report["offenses"][0]
777
+
778
+ self.assertEqual(offense["check"], "stylelint/parse-error")
779
+ self.assertEqual(offense["severity"], "error")
780
+ self.assertEqual(offense["message"], "Unclosed block (CssSyntaxError)")
781
+ self.assertEqual(shopify_lint.exit_code_for([report], "error"), 1)
782
+
783
+ def test_a_rule_offense_in_liquid_keeps_its_severity(self) -> None:
784
+ # A rule offense inside a `{% stylesheet %}` block is graded exactly as
785
+ # one in a standalone `.css` file, and must still block a handoff.
786
+ results = [
787
+ {
788
+ "path": "/theme/sections/td-hero.liquid",
789
+ "warnings": [
790
+ {
791
+ "line": 3,
792
+ "column": 1,
793
+ "rule": "block-no-empty",
794
+ "severity": "error",
795
+ "text": "Empty block (block-no-empty)",
796
+ }
797
+ ],
798
+ }
799
+ ]
800
+
801
+ [report] = shopify_lint.normalize_stylelint_results(results)
802
+
803
+ self.assertEqual(report["offenses"][0]["severity"], "error")
804
+ self.assertEqual(shopify_lint.exit_code_for([report], "error"), 1)
805
+
806
+ def test_an_unrecognized_severity_falls_back_to_warning(self) -> None:
807
+ results = [
808
+ {
809
+ "path": "/theme/assets/td-app.css",
810
+ "warnings": [{"line": 1, "column": 1, "rule": "block-no-empty"}],
811
+ }
812
+ ]
813
+
814
+ [report] = shopify_lint.normalize_stylelint_results(results)
815
+
816
+ self.assertEqual(report["offenses"][0]["severity"], "warning")
465
817
 
466
818
 
467
819
  class MergeReportTests(unittest.TestCase):
820
+ def test_merges_all_three_streams_for_one_file(self) -> None:
821
+ theme_reports = [
822
+ {
823
+ "path": "/repo/sections/td-hero.liquid",
824
+ "offenses": [
825
+ {"check": "HardcodedText", "severity": "warning", "start_row": 12}
826
+ ],
827
+ }
828
+ ]
829
+ js_reports = [
830
+ {
831
+ "path": "/repo/sections/td-hero.liquid",
832
+ "offenses": [
833
+ {"check": "eslint/no-dupe-keys", "severity": "error", "start_row": 3}
834
+ ],
835
+ }
836
+ ]
837
+ css_reports = [
838
+ {
839
+ "path": "/repo/sections/td-hero.liquid",
840
+ "offenses": [
841
+ {
842
+ "check": "stylelint/block-no-empty",
843
+ "severity": "error",
844
+ "start_row": 7,
845
+ }
846
+ ],
847
+ }
848
+ ]
849
+
850
+ merged = shopify_lint.merge_reports(
851
+ theme_reports, js_reports, css_reports, repo_root=Path("/repo")
852
+ )
853
+
854
+ self.assertEqual(len(merged), 1)
855
+ self.assertEqual(
856
+ [offense["check"] for offense in merged[0]["offenses"]],
857
+ ["eslint/no-dupe-keys", "stylelint/block-no-empty", "HardcodedText"],
858
+ )
859
+
468
860
  def test_merges_both_streams_for_one_file_and_sorts_offenses(self) -> None:
469
861
  theme_reports = [
470
862
  {
@@ -582,5 +974,88 @@ class EndToEndJsTests(unittest.TestCase):
582
974
  self.assertEqual(dupe[0]["severity"], "error")
583
975
 
584
976
 
977
+ @unittest.skipUnless(
978
+ (SKILL_ROOT / "stylelint-config-theory" / "dist").is_dir(),
979
+ "run scripts/setup.sh to build stylelint-config-theory",
980
+ )
981
+ class EndToEndCssTests(unittest.TestCase):
982
+ """Proves the Liquid line-mapping contract against the real CSS runner."""
983
+
984
+ def test_reports_stylesheet_block_offenses_at_their_real_line(self) -> None:
985
+ lines = [
986
+ '<div class="td-hero">',
987
+ " <h2>{{ section.settings.heading }}</h2>",
988
+ "</div>",
989
+ "",
990
+ "{% stylesheet %}",
991
+ " .td-hero {",
992
+ " margin: {{ section.settings.gap }}px;",
993
+ " }",
994
+ " .td-broken {}",
995
+ "{% endstylesheet %}",
996
+ ]
997
+ expected_row = lines.index(" .td-broken {}")
998
+
999
+ with tempfile.TemporaryDirectory() as directory:
1000
+ root = Path(directory)
1001
+ section = root / "sections" / "td-hero.liquid"
1002
+ section.parent.mkdir(parents=True)
1003
+ section.write_text("\n".join(lines) + "\n")
1004
+
1005
+ results = shopify_lint.run_css_lint(root, [section])
1006
+ [report] = shopify_lint.normalize_stylelint_results(results)
1007
+
1008
+ empty = [
1009
+ offense
1010
+ for offense in report["offenses"]
1011
+ if offense["check"] == "stylelint/block-no-empty"
1012
+ ]
1013
+
1014
+ self.assertEqual(len(empty), 1)
1015
+ # start_row is zero-indexed, matching Theme Check.
1016
+ self.assertEqual(empty[0]["start_row"], expected_row)
1017
+ self.assertEqual(empty[0]["severity"], "error")
1018
+ # The masked interpolation is graded like any other value now: Liquid
1019
+ # does not belong in a `{% stylesheet %}` block, and Theme Check reports
1020
+ # it separately through `StaticStylesheetAndJavascriptTags`.
1021
+ interpolated_row = lines.index(" margin: {{ section.settings.gap }}px;")
1022
+ self.assertEqual(
1023
+ [
1024
+ offense["check"]
1025
+ for offense in report["offenses"]
1026
+ if offense["start_row"] == interpolated_row
1027
+ ],
1028
+ ["stylelint/declaration-property-value-no-unknown"],
1029
+ )
1030
+
1031
+ def test_grades_a_css_asset_by_the_two_rule_layers(self) -> None:
1032
+ with tempfile.TemporaryDirectory() as directory:
1033
+ root = Path(directory)
1034
+ asset = root / "assets" / "component-td-card.css"
1035
+ asset.parent.mkdir(parents=True)
1036
+ asset.write_text(".td-card {\n margin: 0px;\n}\n.td-empty {}\n")
1037
+
1038
+ results = shopify_lint.run_css_lint(root, [asset])
1039
+ [report] = shopify_lint.normalize_stylelint_results(results)
1040
+
1041
+ severities = {
1042
+ offense["check"]: offense["severity"] for offense in report["offenses"]
1043
+ }
1044
+
1045
+ self.assertEqual(severities["stylelint/block-no-empty"], "error")
1046
+ self.assertEqual(severities["stylelint/length-zero-no-unit"], "warning")
1047
+
1048
+ def test_reports_nothing_for_a_liquid_file_without_a_stylesheet_block(self) -> None:
1049
+ with tempfile.TemporaryDirectory() as directory:
1050
+ root = Path(directory)
1051
+ section = root / "sections" / "td-plain.liquid"
1052
+ section.parent.mkdir(parents=True)
1053
+ section.write_text('<div class="td-plain">Hello</div>\n')
1054
+
1055
+ results = shopify_lint.run_css_lint(root, [section])
1056
+
1057
+ self.assertEqual(shopify_lint.normalize_stylelint_results(results), [])
1058
+
1059
+
585
1060
  if __name__ == "__main__":
586
1061
  unittest.main()
@@ -12,6 +12,13 @@ HardcodedText:
12
12
  enabled: true
13
13
  severity: warning
14
14
 
15
+ # Upstream check, pinned here so the branch lint always fails on it: Shopify
16
+ # serves `{% stylesheet %}` and `{% javascript %}` bodies verbatim, so Liquid
17
+ # written inside one ships to the browser as literal text.
18
+ StaticStylesheetAndJavascriptTags:
19
+ enabled: true
20
+ severity: error
21
+
15
22
  UnusedSectionSettings:
16
23
  enabled: false
17
24
  severity: warning
@@ -112,6 +112,47 @@ All three nullability checks share the same branch and `default`-filter
112
112
  analysis. A guard applies only inside its active `if`, `unless`, `elsif`, or
113
113
  `case` branch and only to the matching setting, metafield, or metaobject.
114
114
 
115
+ ## Left to upstream: Liquid in stylesheet and javascript tags
116
+
117
+ Shopify concatenates the contents of every `{% stylesheet %}` and
118
+ `{% javascript %}` block into the theme's compiled CSS and JavaScript bundles
119
+ **without rendering them**. Liquid written inside one is served to the browser
120
+ verbatim, so `color: {{ section.settings.color }}` ships as those thirty-six
121
+ characters and the declaration is dropped by the CSS parser. It looks like it
122
+ works locally only when the value happens to be absent.
123
+
124
+ Theme Check's own
125
+ [`StaticStylesheetAndJavascriptTags`](https://shopify.dev/docs/storefronts/themes/tools/theme-check/checks/static-stylesheet-and-javascript-tags)
126
+ already reports every Liquid node in those bodies — output tags, control flow,
127
+ and `{% comment %}`, which is not a comment there either — at `error` severity,
128
+ so this package deliberately ships no check of its own. The configuration below
129
+ pins it explicitly rather than inheriting it from `theme-check:recommended`, so
130
+ the branch lint keeps failing on it even if the upstream default changes.
131
+
132
+ Pass dynamic values in through the markup instead:
133
+
134
+ ```liquid
135
+ <div class="td-hero" style="--td-hero-gap: {{ section.settings.gap }}px"></div>
136
+ {% stylesheet %}
137
+ .td-hero { gap: var(--td-hero-gap); }
138
+ {% endstylesheet %}
139
+ ```
140
+
141
+ For JavaScript, read the value from a `data-` attribute or a
142
+ `<script type="application/json">` block — both of which
143
+ `DisallowedScriptOrStyleTag` allows.
144
+
145
+ Its one blind spot is the mirror image: braces the Liquid parser reads as an
146
+ output tag when they are really host-language nesting, such as
147
+ `if (a) {{ b(); }}` or `@media screen {{ .a { color: red; } }}`, are reported as
148
+ Liquid. The masks in `eslint-plugin-theory` and `stylelint-config-theory` treat
149
+ anything between `{{` and `}}` carrying a brace or a semicolon as CSS or
150
+ JavaScript, so they keep linting such a block. If a theme genuinely needs the
151
+ construct, put a
152
+ `{% # theme-check-disable StaticStylesheetAndJavascriptTags %}` comment *before*
153
+ the opening tag — never inside the block, where the comment would itself be
154
+ emitted verbatim.
155
+
115
156
  ## Usage in a project
116
157
 
117
158
  Install the module as a development dependency:
@@ -137,6 +178,13 @@ HardcodedText:
137
178
  enabled: true
138
179
  severity: warning
139
180
 
181
+ # Upstream check, pinned here so the branch lint always fails on it: Shopify
182
+ # serves `{% stylesheet %}` and `{% javascript %}` bodies verbatim, so Liquid
183
+ # written inside one ships to the browser as literal text.
184
+ StaticStylesheetAndJavascriptTags:
185
+ enabled: true
186
+ severity: error
187
+
140
188
  UnusedSectionSettings:
141
189
  enabled: false
142
190
  severity: warning