td-ai-tools 1.2.4 → 1.2.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.
Files changed (31) hide show
  1. package/package.json +1 -1
  2. package/skills/README.md +1 -1
  3. package/skills/shopify-lint/SKILL.md +4 -4
  4. package/skills/shopify-lint/agents/openai.yaml +1 -1
  5. package/skills/shopify-lint/hooks/codex-stop.sh +1 -1
  6. package/skills/shopify-lint/scripts/__pycache__/shopify_lint.cpython-312.pyc +0 -0
  7. package/skills/shopify-lint/scripts/setup.sh +17 -1
  8. package/skills/shopify-lint/tests/__pycache__/test_shopify_lint.cpython-312.pyc +0 -0
  9. package/skills/shopify-lint/theme-check-theory/.theme-check.example.yml +16 -1
  10. package/skills/shopify-lint/theme-check-theory/README.md +89 -18
  11. package/skills/shopify-lint/theme-check-theory/configs/recommended.yml +16 -1
  12. package/skills/shopify-lint/theme-check-theory/package-lock.json +2 -2
  13. package/skills/shopify-lint/theme-check-theory/package.json +1 -1
  14. package/skills/shopify-lint/theme-check-theory/src/checks/disallowed-script-or-style-tag.test.ts +54 -0
  15. package/skills/shopify-lint/theme-check-theory/src/checks/disallowed-script-or-style-tag.ts +77 -0
  16. package/skills/shopify-lint/theme-check-theory/src/checks/hardcoded-text.test.ts +102 -0
  17. package/skills/shopify-lint/theme-check-theory/src/checks/hardcoded-text.ts +171 -0
  18. package/skills/shopify-lint/theme-check-theory/src/checks/unguarded-metafield.test.ts +76 -0
  19. package/skills/shopify-lint/theme-check-theory/src/checks/unguarded-metafield.ts +49 -0
  20. package/skills/shopify-lint/theme-check-theory/src/checks/unguarded-metaobject.test.ts +77 -0
  21. package/skills/shopify-lint/theme-check-theory/src/checks/unguarded-metaobject.ts +42 -0
  22. package/skills/shopify-lint/theme-check-theory/src/checks/unguarded-nullable-setting.test.ts +301 -0
  23. package/skills/shopify-lint/theme-check-theory/src/checks/unguarded-nullable-setting.ts +129 -0
  24. package/skills/shopify-lint/theme-check-theory/src/checks/unused-section-settings.ts +1 -1
  25. package/skills/shopify-lint/theme-check-theory/src/index.test.ts +17 -4
  26. package/skills/shopify-lint/theme-check-theory/src/index.ts +20 -6
  27. package/skills/shopify-lint/theme-check-theory/src/utils/null-guard.ts +79 -0
  28. package/skills/shopify-lint/theme-check-theory/src/utils/nullable-output-check.ts +90 -0
  29. package/skills/visual-regression/scripts/__pycache__/visual_regression.cpython-312.pyc +0 -0
  30. package/skills/shopify-lint/theme-check-theory/src/checks/unguarded-text-setting.test.ts +0 -198
  31. package/skills/shopify-lint/theme-check-theory/src/checks/unguarded-text-setting.ts +0 -143
@@ -0,0 +1,171 @@
1
+ import {
2
+ NodeTypes,
3
+ } from '@shopify/liquid-html-parser';
4
+ import type {
5
+ AttributeNode,
6
+ LiquidHtmlNode,
7
+ LiquidRawTag,
8
+ LiquidVariable,
9
+ TextNode,
10
+ } from '@shopify/liquid-html-parser';
11
+ import {
12
+ LiquidCheckDefinition,
13
+ Severity,
14
+ SourceCodeType,
15
+ } from '@shopify/theme-check-common';
16
+
17
+ const EXEMPT_RAW_TAGS = new Set([
18
+ 'comment',
19
+ 'javascript',
20
+ 'schema',
21
+ 'stylesheet',
22
+ ]);
23
+
24
+ const TEXT_ATTRIBUTES = new Set([
25
+ 'alt',
26
+ 'aria-description',
27
+ 'aria-label',
28
+ 'aria-placeholder',
29
+ 'placeholder',
30
+ 'title',
31
+ ]);
32
+
33
+ const TRANSLATION_FILTERS = new Set(['t', 'translate']);
34
+
35
+ type ValuedAttribute = Extract<AttributeNode, { value: unknown }>;
36
+
37
+ function isValuedAttribute(node: LiquidHtmlNode): node is ValuedAttribute {
38
+ return (
39
+ node.type === NodeTypes.AttrDoubleQuoted ||
40
+ node.type === NodeTypes.AttrSingleQuoted ||
41
+ node.type === NodeTypes.AttrUnquoted
42
+ );
43
+ }
44
+
45
+ function staticAttributeName(node: ValuedAttribute): string | undefined {
46
+ if (
47
+ node.name.length !== 1 ||
48
+ node.name[0].type !== NodeTypes.TextNode
49
+ ) {
50
+ return undefined;
51
+ }
52
+
53
+ return node.name[0].value.toLowerCase();
54
+ }
55
+
56
+ function rawTagAncestor(
57
+ ancestors: LiquidHtmlNode[],
58
+ ): LiquidRawTag | undefined {
59
+ for (let index = ancestors.length - 1; index >= 0; index -= 1) {
60
+ const ancestor = ancestors[index];
61
+ if (ancestor.type === NodeTypes.LiquidRawTag) return ancestor;
62
+ }
63
+
64
+ return undefined;
65
+ }
66
+
67
+ function hasMeaningfulText(value: string): boolean {
68
+ return /[\p{L}\p{N}]/u.test(value);
69
+ }
70
+
71
+ function isHtmlElementName(node: TextNode, parent: LiquidHtmlNode): boolean {
72
+ if (
73
+ parent.type !== NodeTypes.HtmlElement &&
74
+ parent.type !== NodeTypes.HtmlSelfClosingElement
75
+ ) {
76
+ return false;
77
+ }
78
+
79
+ return parent.name.some((part) => part === node);
80
+ }
81
+
82
+ function isTranslation(variable: LiquidVariable): boolean {
83
+ return variable.filters.some(({ name }) => TRANSLATION_FILTERS.has(name));
84
+ }
85
+
86
+ export const HardcodedText: LiquidCheckDefinition = {
87
+ meta: {
88
+ code: 'HardcodedText',
89
+ name: 'Prevent hard-coded storefront text',
90
+ docs: {
91
+ description:
92
+ 'Reports rendered text that should come from a setting, translation, metafield, or metaobject.',
93
+ recommended: true,
94
+ },
95
+ type: SourceCodeType.LiquidHtml,
96
+ severity: Severity.WARNING,
97
+ schema: {},
98
+ },
99
+ create(context) {
100
+ function report(startIndex: number, endIndex: number) {
101
+ context.report({
102
+ message:
103
+ 'Replace hard-coded text with a section setting, translation key, metafield, or metaobject',
104
+ startIndex,
105
+ endIndex,
106
+ });
107
+ }
108
+
109
+ function reportLiteralOutput(variable: LiquidVariable) {
110
+ if (
111
+ variable.expression.type !== NodeTypes.String ||
112
+ !hasMeaningfulText(variable.expression.value) ||
113
+ isTranslation(variable)
114
+ ) {
115
+ return;
116
+ }
117
+
118
+ report(
119
+ variable.expression.position.start,
120
+ variable.expression.position.end,
121
+ );
122
+ }
123
+
124
+ return {
125
+ async TextNode(node, ancestors) {
126
+ if (!hasMeaningfulText(node.value)) return;
127
+
128
+ const rawTag = rawTagAncestor(ancestors);
129
+ if (rawTag) {
130
+ if (EXEMPT_RAW_TAGS.has(rawTag.name.toLowerCase())) return;
131
+ report(node.position.start, node.position.end);
132
+ return;
133
+ }
134
+
135
+ const parent = ancestors.at(-1);
136
+ if (!parent || isHtmlElementName(node, parent)) return;
137
+
138
+ if (isValuedAttribute(parent)) {
139
+ if (!parent.value.some((part) => part === node)) return;
140
+
141
+ const attributeName = staticAttributeName(parent);
142
+ if (attributeName && TEXT_ATTRIBUTES.has(attributeName)) {
143
+ report(node.position.start, node.position.end);
144
+ }
145
+ return;
146
+ }
147
+
148
+ if (parent.type === NodeTypes.RawMarkup) return;
149
+
150
+ if (
151
+ parent.type === NodeTypes.Document ||
152
+ parent.type === NodeTypes.HtmlElement ||
153
+ parent.type === NodeTypes.LiquidBranch ||
154
+ parent.type === NodeTypes.LiquidTag
155
+ ) {
156
+ report(node.position.start, node.position.end);
157
+ }
158
+ },
159
+
160
+ async LiquidVariableOutput(node) {
161
+ if (typeof node.markup === 'string') return;
162
+ reportLiteralOutput(node.markup);
163
+ },
164
+
165
+ async LiquidTag(node) {
166
+ if (node.name !== 'echo' || typeof node.markup === 'string') return;
167
+ reportLiteralOutput(node.markup);
168
+ },
169
+ };
170
+ },
171
+ };
@@ -0,0 +1,76 @@
1
+ import {
2
+ runLiquidCheck,
3
+ } from '@shopify/theme-check-common/dist/test/test-helper';
4
+ import { describe, expect, it } from 'vitest';
5
+
6
+ import { UnguardedMetafield } from './unguarded-metafield';
7
+
8
+ describe('UnguardedMetafield', () => {
9
+ it('reports metafield objects and their descendant values', async () => {
10
+ const source = `
11
+ {{ product.metafields.details.subtitle }}
12
+ {{ product.metafields.details.subtitle.value }}
13
+ {{ collection.metafields['details']['hero'].value }}
14
+ `;
15
+
16
+ const offenses = await runLiquidCheck(UnguardedMetafield, source);
17
+
18
+ expect(offenses).toHaveLength(3);
19
+ expect(offenses[0].message).toBe(
20
+ "The metafield 'product.metafields.details.subtitle' can be nil and is output without a presence guard or default",
21
+ );
22
+ });
23
+
24
+ it.each([
25
+ [
26
+ 'if',
27
+ '{% if product.metafields.details.subtitle %}{{ product.metafields.details.subtitle.value }}{% endif %}',
28
+ ],
29
+ [
30
+ 'unless',
31
+ '{% unless product.metafields.details.subtitle == blank %}{{ product.metafields.details.subtitle.value }}{% endunless %}',
32
+ ],
33
+ [
34
+ 'descendant value',
35
+ '{% if product.metafields.details.subtitle.value %}{{ product.metafields.details.subtitle.value }}{% endif %}',
36
+ ],
37
+ ])('accepts a metafield tested in an %s guard', async (_, source) => {
38
+ expect(await runLiquidCheck(UnguardedMetafield, source)).toEqual([]);
39
+ });
40
+
41
+ it('accepts a default filter on the output', async () => {
42
+ const source =
43
+ "{{ product.metafields.details.subtitle.value | default: 'Not provided' }}";
44
+
45
+ expect(await runLiquidCheck(UnguardedMetafield, source)).toEqual([]);
46
+ });
47
+
48
+ it('does not mistake a different metafield guard for the output guard', async () => {
49
+ const source = `
50
+ {% if product.metafields.details.title %}
51
+ {{ product.metafields.details.subtitle.value }}
52
+ {% endif %}
53
+ `;
54
+
55
+ expect(await runLiquidCheck(UnguardedMetafield, source)).toHaveLength(1);
56
+ });
57
+
58
+ it('ignores namespace collections and dynamic accesses', async () => {
59
+ const source = `
60
+ {{ product.metafields }}
61
+ {{ product.metafields.details }}
62
+ {{ product.metafields[namespace][key].value }}
63
+ `;
64
+
65
+ expect(await runLiquidCheck(UnguardedMetafield, source)).toEqual([]);
66
+ });
67
+
68
+ it('only considers variable output tags', async () => {
69
+ const source = `
70
+ {% assign subtitle = product.metafields.details.subtitle.value %}
71
+ {% echo product.metafields.details.subtitle.value %}
72
+ `;
73
+
74
+ expect(await runLiquidCheck(UnguardedMetafield, source)).toEqual([]);
75
+ });
76
+ });
@@ -0,0 +1,49 @@
1
+ import {
2
+ NodeTypes,
3
+ } from '@shopify/liquid-html-parser';
4
+ import type {
5
+ LiquidVariableLookup,
6
+ } from '@shopify/liquid-html-parser';
7
+
8
+ import {
9
+ createNullableOutputCheck,
10
+ } from '../utils/nullable-output-check';
11
+ import type {
12
+ NullableObjectReference,
13
+ } from '../utils/nullable-output-check';
14
+
15
+ export const UnguardedMetafield = createNullableOutputCheck({
16
+ code: 'UnguardedMetafield',
17
+ name: 'Require guards for metafields',
18
+ description:
19
+ 'Reports metafield output without a presence guard or default filter.',
20
+ objectName: 'metafield',
21
+ getReference: getMetafieldReference,
22
+ });
23
+
24
+ function getMetafieldReference(
25
+ node: LiquidVariableLookup,
26
+ ): NullableObjectReference | undefined {
27
+ if (!node.name) return undefined;
28
+
29
+ const [metafields, namespace, key] = node.lookups;
30
+ if (
31
+ metafields?.type !== NodeTypes.String ||
32
+ metafields.value !== 'metafields' ||
33
+ namespace?.type !== NodeTypes.String ||
34
+ key?.type !== NodeTypes.String
35
+ ) {
36
+ return undefined;
37
+ }
38
+
39
+ const name = `${node.name}.metafields.${namespace.value}.${key.value}`;
40
+ return {
41
+ key: JSON.stringify([
42
+ 'metafield',
43
+ node.name,
44
+ namespace.value,
45
+ key.value,
46
+ ]),
47
+ name,
48
+ };
49
+ }
@@ -0,0 +1,77 @@
1
+ import {
2
+ runLiquidCheck,
3
+ } from '@shopify/theme-check-common/dist/test/test-helper';
4
+ import { describe, expect, it } from 'vitest';
5
+
6
+ import { UnguardedMetaobject } from './unguarded-metaobject';
7
+
8
+ describe('UnguardedMetaobject', () => {
9
+ it('reports metaobject entries and their descendant fields', async () => {
10
+ const source = `
11
+ {{ metaobjects.testimonials.homepage }}
12
+ {{ metaobjects.testimonials.homepage.title }}
13
+ {{ metaobjects['highlights']['washable'].image.value }}
14
+ `;
15
+
16
+ const offenses = await runLiquidCheck(UnguardedMetaobject, source);
17
+
18
+ expect(offenses).toHaveLength(3);
19
+ expect(offenses[0].message).toBe(
20
+ "The metaobject 'metaobjects.testimonials.homepage' can be nil and is output without a presence guard or default",
21
+ );
22
+ });
23
+
24
+ it.each([
25
+ [
26
+ 'if',
27
+ '{% if metaobjects.testimonials.homepage %}{{ metaobjects.testimonials.homepage.title.value }}{% endif %}',
28
+ ],
29
+ [
30
+ 'unless',
31
+ '{% unless metaobjects.testimonials.homepage == nil %}{{ metaobjects.testimonials.homepage.title.value }}{% endunless %}',
32
+ ],
33
+ [
34
+ 'descendant field',
35
+ '{% if metaobjects.testimonials.homepage.title.value %}{{ metaobjects.testimonials.homepage.title.value }}{% endif %}',
36
+ ],
37
+ ])('accepts a metaobject tested in an %s guard', async (_, source) => {
38
+ expect(await runLiquidCheck(UnguardedMetaobject, source)).toEqual([]);
39
+ });
40
+
41
+ it('accepts a default filter on the output', async () => {
42
+ const source =
43
+ "{{ metaobjects.testimonials.homepage.title.value | default: 'Untitled' }}";
44
+
45
+ expect(await runLiquidCheck(UnguardedMetaobject, source)).toEqual([]);
46
+ });
47
+
48
+ it('does not mistake a different metaobject guard for the output guard', async () => {
49
+ const source = `
50
+ {% if metaobjects.testimonials.featured %}
51
+ {{ metaobjects.testimonials.homepage.title.value }}
52
+ {% endif %}
53
+ `;
54
+
55
+ expect(await runLiquidCheck(UnguardedMetaobject, source)).toHaveLength(1);
56
+ });
57
+
58
+ it('ignores type collections, dynamic accesses, and the current template metaobject', async () => {
59
+ const source = `
60
+ {{ metaobjects }}
61
+ {{ metaobjects.testimonials }}
62
+ {{ metaobjects[type][handle].title.value }}
63
+ {{ metaobject.title.value }}
64
+ `;
65
+
66
+ expect(await runLiquidCheck(UnguardedMetaobject, source)).toEqual([]);
67
+ });
68
+
69
+ it('only considers variable output tags', async () => {
70
+ const source = `
71
+ {% assign testimonial = metaobjects.testimonials.homepage %}
72
+ {% echo metaobjects.testimonials.homepage.title.value %}
73
+ `;
74
+
75
+ expect(await runLiquidCheck(UnguardedMetaobject, source)).toEqual([]);
76
+ });
77
+ });
@@ -0,0 +1,42 @@
1
+ import {
2
+ NodeTypes,
3
+ } from '@shopify/liquid-html-parser';
4
+ import type {
5
+ LiquidVariableLookup,
6
+ } from '@shopify/liquid-html-parser';
7
+
8
+ import {
9
+ createNullableOutputCheck,
10
+ } from '../utils/nullable-output-check';
11
+ import type {
12
+ NullableObjectReference,
13
+ } from '../utils/nullable-output-check';
14
+
15
+ export const UnguardedMetaobject = createNullableOutputCheck({
16
+ code: 'UnguardedMetaobject',
17
+ name: 'Require guards for metaobjects',
18
+ description:
19
+ 'Reports metaobject output without a presence guard or default filter.',
20
+ objectName: 'metaobject',
21
+ getReference: getMetaobjectReference,
22
+ });
23
+
24
+ function getMetaobjectReference(
25
+ node: LiquidVariableLookup,
26
+ ): NullableObjectReference | undefined {
27
+ if (node.name !== 'metaobjects') return undefined;
28
+
29
+ const [type, handle] = node.lookups;
30
+ if (
31
+ type?.type !== NodeTypes.String ||
32
+ handle?.type !== NodeTypes.String
33
+ ) {
34
+ return undefined;
35
+ }
36
+
37
+ const name = `metaobjects.${type.value}.${handle.value}`;
38
+ return {
39
+ key: JSON.stringify(['metaobject', type.value, handle.value]),
40
+ name,
41
+ };
42
+ }