td-ai-tools 1.2.1 → 1.2.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.
- package/package.json +1 -1
- package/skills/README.md +1 -0
- package/skills/shopify-lint/SKILL.md +50 -0
- package/skills/shopify-lint/agents/openai.yaml +4 -0
- package/skills/shopify-lint/scripts/setup.sh +60 -0
- package/skills/shopify-lint/scripts/shopify_lint.py +259 -0
- package/skills/shopify-lint/tests/test_shopify_lint.py +137 -0
- package/skills/shopify-lint/theme-check-theory/.theme-check.example.yml +14 -0
- package/skills/shopify-lint/theme-check-theory/README.md +123 -0
- package/skills/shopify-lint/theme-check-theory/configs/recommended.yml +8 -0
- package/skills/shopify-lint/theme-check-theory/package-lock.json +1947 -0
- package/skills/shopify-lint/theme-check-theory/package.json +44 -0
- package/skills/shopify-lint/theme-check-theory/src/checks/unguarded-text-setting.test.ts +198 -0
- package/skills/shopify-lint/theme-check-theory/src/checks/unguarded-text-setting.ts +143 -0
- package/skills/shopify-lint/theme-check-theory/src/checks/unused-section-settings.test.ts +137 -0
- package/skills/shopify-lint/theme-check-theory/src/checks/unused-section-settings.ts +64 -0
- package/skills/shopify-lint/theme-check-theory/src/index.test.ts +20 -0
- package/skills/shopify-lint/theme-check-theory/src/index.ts +11 -0
- package/skills/shopify-lint/theme-check-theory/src/test-utils.ts +31 -0
- package/skills/shopify-lint/theme-check-theory/src/utils/ast.ts +126 -0
- package/skills/shopify-lint/theme-check-theory/tsconfig.build.json +10 -0
- package/skills/shopify-lint/theme-check-theory/tsconfig.json +15 -0
- package/skills/shopify-lint/theme-check-theory/vitest.config.ts +11 -0
package/package.json
CHANGED
package/skills/README.md
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
- `pull-request-statamic`: Generates GitHub pull request descriptions for Statamic and Laravel development by analyzing git diffs and…
|
|
19
19
|
- `record-changes`: Update `docs/changes.md` by summarizing the current branch against the primary development branch.
|
|
20
20
|
- `shopify-cli`: Shopify CLI workflows for theme development.
|
|
21
|
+
- `shopify-lint`: Run Shopify CLI Theme Check with Theory Digital's bundled custom checks while reporting and failing only on offenses in files modified on the current Git branch.
|
|
21
22
|
- `stylesheet-migration`: Migrate Shopify Liquid `{% stylesheet %}` blocks into theme CSS assets using bundled Python scripts.
|
|
22
23
|
- `td-js-vanilla-rules`: Theory Digital vanilla JavaScript standards for Shopify theme work.
|
|
23
24
|
- `td-review`: Run parallel code review agents on a PR (including TD theme compliance) and produce a synthesized findings…
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: shopify-lint
|
|
3
|
+
version: 1.0.0
|
|
4
|
+
description: Run Shopify CLI Theme Check with Theory Digital's bundled custom checks while reporting and failing only on offenses in files modified on the current Git branch. Use when Codex needs to lint a Shopify theme, validate branch-scoped Liquid or theme changes, enforce Theory theme rules, or avoid surfacing pre-existing Theme Check offenses from untouched files.
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Shopify Lint
|
|
8
|
+
|
|
9
|
+
## Setup
|
|
10
|
+
|
|
11
|
+
The bundled `theme-check-theory` custom-check package ships as TypeScript source only; its `node_modules` and compiled `dist/` are not committed. Run setup once before the first run (and after upgrading the skill):
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
bash .agents/skills/shopify-lint/scripts/setup.sh
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Installing with `td-ai-tools install --setup shopify-lint` runs this automatically. The script:
|
|
18
|
+
|
|
19
|
+
1. Runs `npm install && npm run build` inside `theme-check-theory/`, producing `dist/index.js` — the CommonJS entry point the root `.theme-check.yml` requires.
|
|
20
|
+
2. Writes a `.theme-check.yml` at the project root wiring in the bundled checks. If one already exists it is left untouched; ensure its `require:` list includes `./.agents/skills/shopify-lint/theme-check-theory`.
|
|
21
|
+
|
|
22
|
+
Keep these dependencies inside `theme-check-theory/node_modules`; do not install Node dependencies at the theme root.
|
|
23
|
+
|
|
24
|
+
## Usage
|
|
25
|
+
|
|
26
|
+
Run the bundled Python script from the Shopify theme root:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
python3 .agents/skills/shopify-lint/scripts/shopify_lint.py --path .
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The script runs `shopify theme check --output json`, identifies files changed since the current branch's merge-base, adds staged, unstaged, and untracked files, and emits only reports whose paths are in that set. The root `.theme-check.yml` directly requires the bundled `theme-check-theory` package from this skill directory.
|
|
33
|
+
|
|
34
|
+
## Workflow
|
|
35
|
+
|
|
36
|
+
1. Confirm `shopify` and `python3` are available.
|
|
37
|
+
2. Run the bundled script instead of calling `shopify theme check` directly.
|
|
38
|
+
3. Treat exit code `0` as no failing offenses in modified files, `1` as filtered offenses at or above the fail level, and `2` as a Git, CLI, or JSON-processing error.
|
|
39
|
+
4. Fix reported issues and rerun until the command passes. Do not fix offenses in untouched files unless the user expands the scope.
|
|
40
|
+
5. Keep custom-check dependencies inside `theme-check-theory/node_modules`. Do not install Node dependencies at the theme root.
|
|
41
|
+
|
|
42
|
+
The default base is `origin/HEAD`, then `origin/main`, `main`, `origin/master`, or `master`. Override it when needed:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
python3 .agents/skills/shopify-lint/scripts/shopify_lint.py --path . --base-ref origin/develop
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Set `SHOPIFY_LINT_BASE_REF` for the same override in automation. Use `--format json` for machine-readable filtered output and `--fail-level warning` or `--fail-level info` for stricter runs. When passing `--config <path>`, preserve the bundled package's `require` entry or the Theory checks will not load.
|
|
49
|
+
|
|
50
|
+
Do not use Theme Check auto-correction through this workflow because it can modify untouched files before filtering.
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
# shopify-lint setup
|
|
5
|
+
#
|
|
6
|
+
# Run automatically by the installer (`--setup`) from the installed skill
|
|
7
|
+
# directory, or manually with `bash scripts/setup.sh`. It:
|
|
8
|
+
# 1. Builds the bundled `theme-check-theory` custom-check package
|
|
9
|
+
# (its node_modules and dist/ are intentionally not committed).
|
|
10
|
+
# 2. Writes a `.theme-check.yml` at the project root that wires the bundled
|
|
11
|
+
# checks into Shopify CLI Theme Check.
|
|
12
|
+
|
|
13
|
+
SKILL_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
|
14
|
+
PKG_DIR="$SKILL_DIR/theme-check-theory"
|
|
15
|
+
|
|
16
|
+
# 1. Build the bundled custom-check package.
|
|
17
|
+
echo "shopify-lint setup: building theme-check-theory in $PKG_DIR"
|
|
18
|
+
cd "$PKG_DIR"
|
|
19
|
+
npm install
|
|
20
|
+
npm run build
|
|
21
|
+
|
|
22
|
+
# 2. Generate the project-root .theme-check.yml.
|
|
23
|
+
#
|
|
24
|
+
# The installed layout is <project>/<.agents|.claude>/skills/shopify-lint, so
|
|
25
|
+
# the project root is three levels above the skill directory. Guard against
|
|
26
|
+
# running outside that layout (e.g. from the catalog repo) to avoid writing the
|
|
27
|
+
# config into an unexpected directory.
|
|
28
|
+
TARGET_DIR="$(cd "$SKILL_DIR/../.." && pwd)"
|
|
29
|
+
TARGET_BASE="$(basename "$TARGET_DIR")"
|
|
30
|
+
if [[ "$TARGET_BASE" != ".agents" && "$TARGET_BASE" != ".claude" ]]; then
|
|
31
|
+
echo "shopify-lint setup: unrecognized install layout ($TARGET_DIR); skipping .theme-check.yml generation." >&2
|
|
32
|
+
exit 0
|
|
33
|
+
fi
|
|
34
|
+
|
|
35
|
+
PROJECT_ROOT="$(dirname "$TARGET_DIR")"
|
|
36
|
+
THEME_CHECK_FILE="$PROJECT_ROOT/.theme-check.yml"
|
|
37
|
+
|
|
38
|
+
if [[ -f "$THEME_CHECK_FILE" ]]; then
|
|
39
|
+
echo "shopify-lint setup: $THEME_CHECK_FILE already exists; leaving it unchanged."
|
|
40
|
+
echo " Ensure its 'require:' list includes ./.agents/skills/shopify-lint/theme-check-theory"
|
|
41
|
+
exit 0
|
|
42
|
+
fi
|
|
43
|
+
|
|
44
|
+
cat > "$THEME_CHECK_FILE" <<'YAML'
|
|
45
|
+
extends:
|
|
46
|
+
- theme-check:recommended
|
|
47
|
+
|
|
48
|
+
require:
|
|
49
|
+
- ./.agents/skills/shopify-lint/theme-check-theory
|
|
50
|
+
|
|
51
|
+
UnusedSectionSettings:
|
|
52
|
+
enabled: true
|
|
53
|
+
severity: warning
|
|
54
|
+
|
|
55
|
+
UnguardedTextSetting:
|
|
56
|
+
enabled: true
|
|
57
|
+
severity: warning
|
|
58
|
+
YAML
|
|
59
|
+
|
|
60
|
+
echo "shopify-lint setup: wrote $THEME_CHECK_FILE"
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Run Shopify Theme Check and report offenses only for branch-modified files."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
SEVERITY_RANK = {"error": 0, "warning": 1, "info": 2}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ShopifyLintError(RuntimeError):
|
|
19
|
+
"""Raised when Git or Shopify CLI cannot produce filterable results."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def run_command(command: list[str], cwd: Path) -> subprocess.CompletedProcess[str]:
|
|
23
|
+
return subprocess.run(
|
|
24
|
+
command,
|
|
25
|
+
cwd=cwd,
|
|
26
|
+
capture_output=True,
|
|
27
|
+
text=True,
|
|
28
|
+
check=False,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def git_output(repo_root: Path, *arguments: str) -> str:
|
|
33
|
+
result = run_command(["git", *arguments], repo_root)
|
|
34
|
+
if result.returncode != 0:
|
|
35
|
+
detail = result.stderr.strip() or result.stdout.strip()
|
|
36
|
+
raise ShopifyLintError(f"Git command failed: {detail}")
|
|
37
|
+
return result.stdout
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def find_repo_root(theme_path: Path) -> Path:
|
|
41
|
+
result = run_command(["git", "rev-parse", "--show-toplevel"], theme_path)
|
|
42
|
+
if result.returncode != 0:
|
|
43
|
+
raise ShopifyLintError("Theme path must be inside a Git repository.")
|
|
44
|
+
return Path(result.stdout.strip()).resolve()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def discover_base_ref(repo_root: Path) -> str:
|
|
48
|
+
configured = os.environ.get("SHOPIFY_LINT_BASE_REF")
|
|
49
|
+
if configured:
|
|
50
|
+
return configured
|
|
51
|
+
|
|
52
|
+
remote_head = run_command(
|
|
53
|
+
["git", "symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"],
|
|
54
|
+
repo_root,
|
|
55
|
+
)
|
|
56
|
+
candidates = [remote_head.stdout.strip(), "origin/main", "main", "origin/master", "master"]
|
|
57
|
+
for candidate in candidates:
|
|
58
|
+
if not candidate:
|
|
59
|
+
continue
|
|
60
|
+
result = run_command(
|
|
61
|
+
["git", "rev-parse", "--verify", "--quiet", f"{candidate}^{{commit}}"],
|
|
62
|
+
repo_root,
|
|
63
|
+
)
|
|
64
|
+
if result.returncode == 0:
|
|
65
|
+
return candidate
|
|
66
|
+
|
|
67
|
+
raise ShopifyLintError(
|
|
68
|
+
"Could not determine the base branch. Pass --base-ref or set "
|
|
69
|
+
"SHOPIFY_LINT_BASE_REF."
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def parse_nul_paths(output: str) -> set[str]:
|
|
74
|
+
return {path for path in output.split("\0") if path}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def collect_changed_files(repo_root: Path, base_ref: str) -> set[str]:
|
|
78
|
+
merge_base = git_output(repo_root, "merge-base", "HEAD", base_ref).strip()
|
|
79
|
+
committed = git_output(
|
|
80
|
+
repo_root,
|
|
81
|
+
"diff",
|
|
82
|
+
"--name-only",
|
|
83
|
+
"--diff-filter=ACMR",
|
|
84
|
+
"-z",
|
|
85
|
+
f"{merge_base}..HEAD",
|
|
86
|
+
)
|
|
87
|
+
working_tree = git_output(
|
|
88
|
+
repo_root,
|
|
89
|
+
"diff",
|
|
90
|
+
"--name-only",
|
|
91
|
+
"--diff-filter=ACMR",
|
|
92
|
+
"-z",
|
|
93
|
+
"HEAD",
|
|
94
|
+
)
|
|
95
|
+
untracked = git_output(
|
|
96
|
+
repo_root,
|
|
97
|
+
"ls-files",
|
|
98
|
+
"--others",
|
|
99
|
+
"--exclude-standard",
|
|
100
|
+
"-z",
|
|
101
|
+
)
|
|
102
|
+
return parse_nul_paths(committed) | parse_nul_paths(working_tree) | parse_nul_paths(
|
|
103
|
+
untracked
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def run_theme_check(
|
|
108
|
+
theme_path: Path, fail_level: str, config: Path | None
|
|
109
|
+
) -> list[dict[str, Any]]:
|
|
110
|
+
command = [
|
|
111
|
+
"shopify",
|
|
112
|
+
"theme",
|
|
113
|
+
"check",
|
|
114
|
+
"--output",
|
|
115
|
+
"json",
|
|
116
|
+
"--path",
|
|
117
|
+
str(theme_path),
|
|
118
|
+
"--fail-level",
|
|
119
|
+
fail_level,
|
|
120
|
+
]
|
|
121
|
+
if config:
|
|
122
|
+
command.extend(["--config", str(config)])
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
result = subprocess.run(
|
|
126
|
+
command,
|
|
127
|
+
capture_output=True,
|
|
128
|
+
text=True,
|
|
129
|
+
check=False,
|
|
130
|
+
)
|
|
131
|
+
except FileNotFoundError as error:
|
|
132
|
+
raise ShopifyLintError("Shopify CLI is not installed or is not on PATH.") from error
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
reports = json.loads(result.stdout)
|
|
136
|
+
except json.JSONDecodeError as error:
|
|
137
|
+
detail = result.stderr.strip() or result.stdout.strip() or "no output"
|
|
138
|
+
raise ShopifyLintError(f"Shopify Theme Check failed: {detail}") from error
|
|
139
|
+
|
|
140
|
+
if not isinstance(reports, list):
|
|
141
|
+
raise ShopifyLintError("Shopify Theme Check returned an unexpected JSON shape.")
|
|
142
|
+
return reports
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def repo_relative_path(path: str, repo_root: Path) -> str | None:
|
|
146
|
+
candidate = Path(path)
|
|
147
|
+
if not candidate.is_absolute():
|
|
148
|
+
candidate = repo_root / candidate
|
|
149
|
+
try:
|
|
150
|
+
return candidate.resolve().relative_to(repo_root.resolve()).as_posix()
|
|
151
|
+
except ValueError:
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def filter_reports(
|
|
156
|
+
reports: list[dict[str, Any]], changed_files: set[str], repo_root: Path
|
|
157
|
+
) -> list[dict[str, Any]]:
|
|
158
|
+
return [
|
|
159
|
+
report
|
|
160
|
+
for report in reports
|
|
161
|
+
if repo_relative_path(str(report.get("path", "")), repo_root) in changed_files
|
|
162
|
+
]
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def normalized_reports(
|
|
166
|
+
reports: list[dict[str, Any]], repo_root: Path
|
|
167
|
+
) -> list[dict[str, Any]]:
|
|
168
|
+
normalized = []
|
|
169
|
+
for report in reports:
|
|
170
|
+
item = dict(report)
|
|
171
|
+
item["path"] = repo_relative_path(str(report.get("path", "")), repo_root)
|
|
172
|
+
normalized.append(item)
|
|
173
|
+
return normalized
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def render_json(reports: list[dict[str, Any]], repo_root: Path) -> str:
|
|
177
|
+
return json.dumps(normalized_reports(reports, repo_root), indent=2)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def render_text(reports: list[dict[str, Any]], repo_root: Path) -> str:
|
|
181
|
+
if not reports:
|
|
182
|
+
return "No Theme Check offenses found in files modified on this branch."
|
|
183
|
+
|
|
184
|
+
lines = []
|
|
185
|
+
offense_count = 0
|
|
186
|
+
for report in normalized_reports(reports, repo_root):
|
|
187
|
+
lines.append(str(report["path"]))
|
|
188
|
+
for offense in report.get("offenses", []):
|
|
189
|
+
offense_count += 1
|
|
190
|
+
severity = str(offense.get("severity", "unknown")).upper()
|
|
191
|
+
check = offense.get("check", "UnknownCheck")
|
|
192
|
+
row = offense.get("start_row", 0)
|
|
193
|
+
column = offense.get("start_column", 0)
|
|
194
|
+
message = offense.get("message", "")
|
|
195
|
+
lines.append(f" {severity} {check} {row}:{column} {message}".rstrip())
|
|
196
|
+
lines.append("")
|
|
197
|
+
|
|
198
|
+
file_label = "file" if len(reports) == 1 else "files"
|
|
199
|
+
offense_label = "offense" if offense_count == 1 else "offenses"
|
|
200
|
+
lines.append(
|
|
201
|
+
f"Theme Check found {offense_count} {offense_label} in "
|
|
202
|
+
f"{len(reports)} modified {file_label}."
|
|
203
|
+
)
|
|
204
|
+
return "\n".join(lines)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def exit_code_for(reports: list[dict[str, Any]], fail_level: str) -> int:
|
|
208
|
+
threshold = SEVERITY_RANK[fail_level]
|
|
209
|
+
for report in reports:
|
|
210
|
+
for offense in report.get("offenses", []):
|
|
211
|
+
severity = str(offense.get("severity", "info")).lower()
|
|
212
|
+
if SEVERITY_RANK.get(severity, 0) <= threshold:
|
|
213
|
+
return 1
|
|
214
|
+
return 0
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
218
|
+
parser = argparse.ArgumentParser(
|
|
219
|
+
description="Run Shopify Theme Check for files modified on the current branch."
|
|
220
|
+
)
|
|
221
|
+
parser.add_argument("--path", type=Path, default=Path.cwd(), help="Theme root path.")
|
|
222
|
+
parser.add_argument("--base-ref", help="Base branch or commit used for branch changes.")
|
|
223
|
+
parser.add_argument(
|
|
224
|
+
"--fail-level",
|
|
225
|
+
choices=tuple(SEVERITY_RANK),
|
|
226
|
+
default="error",
|
|
227
|
+
help="Minimum filtered severity that returns exit code 1.",
|
|
228
|
+
)
|
|
229
|
+
parser.add_argument("--config", type=Path, help="Theme Check config path.")
|
|
230
|
+
parser.add_argument(
|
|
231
|
+
"--format", choices=("text", "json"), default="text", help="Filtered output format."
|
|
232
|
+
)
|
|
233
|
+
return parser
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def main() -> int:
|
|
237
|
+
arguments = build_parser().parse_args()
|
|
238
|
+
theme_path = arguments.path.resolve()
|
|
239
|
+
try:
|
|
240
|
+
repo_root = find_repo_root(theme_path)
|
|
241
|
+
base_ref = arguments.base_ref or discover_base_ref(repo_root)
|
|
242
|
+
changed_files = collect_changed_files(repo_root, base_ref)
|
|
243
|
+
reports = run_theme_check(theme_path, arguments.fail_level, arguments.config)
|
|
244
|
+
filtered = filter_reports(reports, changed_files, repo_root)
|
|
245
|
+
except ShopifyLintError as error:
|
|
246
|
+
print(f"shopify-lint: {error}", file=sys.stderr)
|
|
247
|
+
return 2
|
|
248
|
+
|
|
249
|
+
output = (
|
|
250
|
+
render_json(filtered, repo_root)
|
|
251
|
+
if arguments.format == "json"
|
|
252
|
+
else render_text(filtered, repo_root)
|
|
253
|
+
)
|
|
254
|
+
print(output)
|
|
255
|
+
return exit_code_for(filtered, arguments.fail_level)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
if __name__ == "__main__":
|
|
259
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Tests for the branch-scoped Shopify Theme Check runner."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
import unittest
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from unittest.mock import patch
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
15
|
+
sys.path.insert(0, str(SKILL_ROOT / "scripts"))
|
|
16
|
+
|
|
17
|
+
import shopify_lint # noqa: E402
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ChangedFilesTests(unittest.TestCase):
|
|
21
|
+
def run_git(self, root: Path, *args: str) -> None:
|
|
22
|
+
subprocess.run(["git", *args], cwd=root, check=True, capture_output=True, text=True)
|
|
23
|
+
|
|
24
|
+
def test_collects_committed_staged_unstaged_and_untracked_files(self) -> None:
|
|
25
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
26
|
+
root = Path(directory)
|
|
27
|
+
self.run_git(root, "init", "-q", "-b", "main")
|
|
28
|
+
self.run_git(root, "config", "user.email", "test@example.com")
|
|
29
|
+
self.run_git(root, "config", "user.name", "Test User")
|
|
30
|
+
|
|
31
|
+
for name in ("committed.liquid", "staged.liquid", "unstaged.liquid"):
|
|
32
|
+
(root / name).write_text("initial\n")
|
|
33
|
+
self.run_git(root, "add", ".")
|
|
34
|
+
self.run_git(root, "commit", "-qm", "initial")
|
|
35
|
+
self.run_git(root, "switch", "-qc", "feature")
|
|
36
|
+
|
|
37
|
+
(root / "committed.liquid").write_text("branch change\n")
|
|
38
|
+
self.run_git(root, "add", "committed.liquid")
|
|
39
|
+
self.run_git(root, "commit", "-qm", "branch change")
|
|
40
|
+
(root / "staged.liquid").write_text("staged change\n")
|
|
41
|
+
self.run_git(root, "add", "staged.liquid")
|
|
42
|
+
(root / "unstaged.liquid").write_text("unstaged change\n")
|
|
43
|
+
(root / "untracked.liquid").write_text("untracked\n")
|
|
44
|
+
|
|
45
|
+
changed = shopify_lint.collect_changed_files(root, "main")
|
|
46
|
+
|
|
47
|
+
self.assertEqual(
|
|
48
|
+
changed,
|
|
49
|
+
{
|
|
50
|
+
"committed.liquid",
|
|
51
|
+
"staged.liquid",
|
|
52
|
+
"unstaged.liquid",
|
|
53
|
+
"untracked.liquid",
|
|
54
|
+
},
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class ThemeCheckTests(unittest.TestCase):
|
|
59
|
+
def test_runs_shopify_cli_with_json_output(self) -> None:
|
|
60
|
+
completed = subprocess.CompletedProcess([], 1, stdout="[]", stderr="")
|
|
61
|
+
|
|
62
|
+
with patch.object(shopify_lint.subprocess, "run", return_value=completed) as run:
|
|
63
|
+
result = shopify_lint.run_theme_check(Path("/theme"), "warning", None)
|
|
64
|
+
|
|
65
|
+
self.assertEqual(result, [])
|
|
66
|
+
run.assert_called_once_with(
|
|
67
|
+
[
|
|
68
|
+
"shopify",
|
|
69
|
+
"theme",
|
|
70
|
+
"check",
|
|
71
|
+
"--output",
|
|
72
|
+
"json",
|
|
73
|
+
"--path",
|
|
74
|
+
"/theme",
|
|
75
|
+
"--fail-level",
|
|
76
|
+
"warning",
|
|
77
|
+
],
|
|
78
|
+
capture_output=True,
|
|
79
|
+
text=True,
|
|
80
|
+
check=False,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
def test_filters_reports_to_changed_repo_relative_paths(self) -> None:
|
|
84
|
+
root = Path("/repo")
|
|
85
|
+
reports = [
|
|
86
|
+
{
|
|
87
|
+
"path": "/repo/sections/td-changed.liquid",
|
|
88
|
+
"offenses": [{"check": "ChangedCheck", "severity": "error"}],
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
"path": "/repo/sections/unchanged.liquid",
|
|
92
|
+
"offenses": [{"check": "IgnoredCheck", "severity": "error"}],
|
|
93
|
+
},
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
filtered = shopify_lint.filter_reports(
|
|
97
|
+
reports, {"sections/td-changed.liquid"}, root
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
self.assertEqual(filtered, [reports[0]])
|
|
101
|
+
|
|
102
|
+
def test_json_output_contains_only_changed_file_reports(self) -> None:
|
|
103
|
+
reports = [
|
|
104
|
+
{
|
|
105
|
+
"path": "/repo/sections/td-changed.liquid",
|
|
106
|
+
"offenses": [{"check": "LiquidHTMLSyntaxError", "severity": "error"}],
|
|
107
|
+
}
|
|
108
|
+
]
|
|
109
|
+
|
|
110
|
+
rendered = shopify_lint.render_json(reports, Path("/repo"))
|
|
111
|
+
|
|
112
|
+
self.assertEqual(
|
|
113
|
+
json.loads(rendered),
|
|
114
|
+
[
|
|
115
|
+
{
|
|
116
|
+
"path": "sections/td-changed.liquid",
|
|
117
|
+
"offenses": [
|
|
118
|
+
{"check": "LiquidHTMLSyntaxError", "severity": "error"}
|
|
119
|
+
],
|
|
120
|
+
}
|
|
121
|
+
],
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
def test_fail_level_is_recomputed_from_filtered_offenses(self) -> None:
|
|
125
|
+
warning_report = [
|
|
126
|
+
{
|
|
127
|
+
"path": "changed.liquid",
|
|
128
|
+
"offenses": [{"check": "RemoteAsset", "severity": "warning"}],
|
|
129
|
+
}
|
|
130
|
+
]
|
|
131
|
+
|
|
132
|
+
self.assertEqual(shopify_lint.exit_code_for(warning_report, "error"), 0)
|
|
133
|
+
self.assertEqual(shopify_lint.exit_code_for(warning_report, "warning"), 1)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
if __name__ == "__main__":
|
|
137
|
+
unittest.main()
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# theme-check-theory
|
|
2
|
+
|
|
3
|
+
Theory Digital's custom [Shopify Theme Check](https://shopify.dev/docs/storefronts/themes/tools/theme-check)
|
|
4
|
+
rules — house standards enforced on top of `theme-check:recommended`.
|
|
5
|
+
|
|
6
|
+
Built for the TypeScript Theme Check engine (`@shopify/theme-check-common` v3)
|
|
7
|
+
integrated into Shopify CLI. It is not compatible with the archived Ruby
|
|
8
|
+
`Shopify/theme-check` project.
|
|
9
|
+
|
|
10
|
+
## Checks
|
|
11
|
+
|
|
12
|
+
| Code | Severity | What it catches |
|
|
13
|
+
| --- | --- | --- |
|
|
14
|
+
| `UnusedSectionSettings` | warning | A setting declared in `{% schema %}` that is never referenced in the file. |
|
|
15
|
+
| `UnguardedTextSetting` | warning | A text-like setting output with `{{ }}` and no presence guard (`{% if %}`/`{% unless %}`) or `\| default`. |
|
|
16
|
+
|
|
17
|
+
### UnusedSectionSettings
|
|
18
|
+
|
|
19
|
+
This single-file check collects every `id` under `settings` and
|
|
20
|
+
`blocks[].settings` in the schema, then collects static
|
|
21
|
+
`section.settings.<id>` and `block.settings.<id>` references in the Liquid. It
|
|
22
|
+
reports every declared setting that has no matching reference.
|
|
23
|
+
|
|
24
|
+
If a file uses dynamic access such as `section.settings[key]`, or passes an
|
|
25
|
+
entire settings object elsewhere, the check cannot prove which setting is
|
|
26
|
+
unused. It therefore stays silent for the whole file to avoid false positives.
|
|
27
|
+
|
|
28
|
+
### UnguardedTextSetting
|
|
29
|
+
|
|
30
|
+
Text-like settings render as an empty string when a merchant leaves them blank,
|
|
31
|
+
so outputting them unguarded is a recurring bug source. A setting is considered
|
|
32
|
+
handled if, anywhere in the file, it is either tested in an `if`, `unless`,
|
|
33
|
+
`elsif`, or `case` conditional, or output with a `default` filter.
|
|
34
|
+
|
|
35
|
+
Version 1 is deliberately coarse and per-file. It favors near-zero false
|
|
36
|
+
positives over catching a setting guarded in one branch and output raw in
|
|
37
|
+
another.
|
|
38
|
+
|
|
39
|
+
The setting types considered text-like are configurable with
|
|
40
|
+
`textualSettingTypes`. The defaults are `text`, `textarea`, `richtext`,
|
|
41
|
+
`inline_richtext`, `html`, `liquid`, and `url`.
|
|
42
|
+
|
|
43
|
+
```yaml
|
|
44
|
+
UnguardedTextSetting:
|
|
45
|
+
enabled: true
|
|
46
|
+
severity: warning
|
|
47
|
+
textualSettingTypes:
|
|
48
|
+
- text
|
|
49
|
+
- textarea
|
|
50
|
+
- image_picker
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Usage in a project
|
|
54
|
+
|
|
55
|
+
Install the module as a development dependency:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
npm install --save-dev theme-check-theory
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Reference its CommonJS entry point from the project's `.theme-check.yml`:
|
|
62
|
+
|
|
63
|
+
```yaml
|
|
64
|
+
extends:
|
|
65
|
+
- theme-check:recommended
|
|
66
|
+
|
|
67
|
+
require:
|
|
68
|
+
- ./node_modules/theme-check-theory
|
|
69
|
+
|
|
70
|
+
UnusedSectionSettings:
|
|
71
|
+
enabled: true
|
|
72
|
+
severity: warning
|
|
73
|
+
|
|
74
|
+
UnguardedTextSetting:
|
|
75
|
+
enabled: true
|
|
76
|
+
severity: warning
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
See [`.theme-check.example.yml`](.theme-check.example.yml) for a copyable
|
|
80
|
+
configuration. The custom-check-only settings are also available in
|
|
81
|
+
[`configs/recommended.yml`](configs/recommended.yml).
|
|
82
|
+
|
|
83
|
+
Run Theme Check through Shopify CLI:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
shopify theme check
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## CI
|
|
90
|
+
|
|
91
|
+
Start new checks at `warning` or `info` so they surface without blocking.
|
|
92
|
+
Review the findings across existing themes, then promote them to `error` once
|
|
93
|
+
the themes are clean.
|
|
94
|
+
|
|
95
|
+
```yaml
|
|
96
|
+
# .github/workflows/theme-check.yml
|
|
97
|
+
- run: npm ci
|
|
98
|
+
- run: npx shopify theme check --fail-level error
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Development
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
npm install
|
|
105
|
+
npm test
|
|
106
|
+
npm run build
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Tests use `runLiquidCheck` and `check` from
|
|
110
|
+
`@shopify/theme-check-common/dist/test/test-helper`, so Vitest exercises the
|
|
111
|
+
real Theme Check parser and traversal rather than a mocked AST.
|
|
112
|
+
|
|
113
|
+
## Adding a check
|
|
114
|
+
|
|
115
|
+
1. Create `src/checks/<name>.ts` exporting a `LiquidCheckDefinition`.
|
|
116
|
+
2. Add it to the `checks` array in `src/index.ts`.
|
|
117
|
+
3. Add `src/checks/<name>.test.ts` with true-positive and false-positive cases.
|
|
118
|
+
4. Add it to `configs/recommended.yml`.
|
|
119
|
+
|
|
120
|
+
Shared schema parsing and setting-reference helpers live in `src/utils/ast.ts`.
|
|
121
|
+
Their node shapes are verified against `@shopify/liquid-html-parser` 2.9.x;
|
|
122
|
+
run the tests when upgrading that dependency.
|
|
123
|
+
|