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.
- package/package.json +1 -1
- package/skills/README.md +2 -2
- package/skills/shopify-lint/SKILL.md +26 -18
- package/skills/shopify-lint/eslint-plugin-theory/README.md +25 -8
- package/skills/shopify-lint/eslint-plugin-theory/src/liquid/mask.test.ts +41 -0
- package/skills/shopify-lint/eslint-plugin-theory/src/liquid/mask.ts +41 -2
- package/skills/shopify-lint/hooks/claude-stop.sh +2 -3
- package/skills/shopify-lint/hooks/codex-stop.sh +2 -3
- package/skills/shopify-lint/scripts/__pycache__/shopify_lint.cpython-312.pyc +0 -0
- package/skills/shopify-lint/scripts/setup.sh +21 -2
- package/skills/shopify-lint/scripts/shopify_lint.py +179 -30
- package/skills/shopify-lint/stylelint-config-theory/.stylelintignore +9 -0
- package/skills/shopify-lint/stylelint-config-theory/README.md +144 -0
- package/skills/shopify-lint/stylelint-config-theory/bin/lint-css.mjs +206 -0
- package/skills/shopify-lint/stylelint-config-theory/package-lock.json +3110 -0
- package/skills/shopify-lint/stylelint-config-theory/package.json +47 -0
- package/skills/shopify-lint/stylelint-config-theory/src/build.test.ts +31 -0
- package/skills/shopify-lint/stylelint-config-theory/src/config.test.ts +150 -0
- package/skills/shopify-lint/stylelint-config-theory/src/config.ts +109 -0
- package/skills/shopify-lint/stylelint-config-theory/src/index.ts +15 -0
- package/skills/shopify-lint/stylelint-config-theory/src/integration.test.ts +187 -0
- package/skills/shopify-lint/stylelint-config-theory/src/liquid/mask.test.ts +300 -0
- package/skills/shopify-lint/stylelint-config-theory/src/liquid/mask.ts +203 -0
- package/skills/shopify-lint/stylelint-config-theory/src/types/shared-configs.d.ts +26 -0
- package/skills/shopify-lint/stylelint-config-theory/tsconfig.build.json +9 -0
- package/skills/shopify-lint/stylelint-config-theory/tsconfig.json +14 -0
- package/skills/shopify-lint/stylelint-config-theory/vitest.config.ts +11 -0
- package/skills/shopify-lint/tests/__pycache__/test_shopify_lint.cpython-312.pyc +0 -0
- package/skills/shopify-lint/tests/test_shopify_lint.py +483 -8
- package/skills/shopify-lint/theme-check-theory/.theme-check.example.yml +7 -0
- package/skills/shopify-lint/theme-check-theory/README.md +48 -0
- package/skills/shopify-pre-pr/SKILL.md +129 -0
- package/skills/shopify-pre-pr/agents/openai.yaml +4 -0
- package/skills/shopify-pre-pr/references/td-change-delimiters.md +88 -0
- package/skills/shopify-pre-pr/scripts/__pycache__/test_vendor_change_audit.cpython-312.pyc +0 -0
- package/skills/shopify-pre-pr/scripts/__pycache__/vendor_change_audit.cpython-312.pyc +0 -0
- package/skills/shopify-pre-pr/scripts/test_vendor_change_audit.py +424 -0
- package/skills/shopify-pre-pr/scripts/vendor_change_audit.py +540 -0
- package/skills/record-changes/SKILL.md +0 -76
- package/skills/record-changes/agents/openai.yaml +0 -4
- /package/skills/{record-changes → shopify-pre-pr}/scripts/branch_diff_context.py +0 -0
|
@@ -16,8 +16,10 @@ SEVERITY_RANK = {"error": 0, "warning": 1, "info": 2}
|
|
|
16
16
|
|
|
17
17
|
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
18
18
|
ESLINT_RUNNER = SKILL_ROOT / "eslint-plugin-theory" / "bin" / "lint-js.mjs"
|
|
19
|
+
STYLELINT_RUNNER = SKILL_ROOT / "stylelint-config-theory" / "bin" / "lint-css.mjs"
|
|
19
20
|
|
|
20
21
|
JS_EXTENSIONS = (".js", ".mjs")
|
|
22
|
+
CSS_EXTENSIONS = (".css",)
|
|
21
23
|
LIQUID_EXTENSION = ".liquid"
|
|
22
24
|
|
|
23
25
|
# Standalone JavaScript is linted only when its filename carries the Theory
|
|
@@ -26,15 +28,21 @@ LIQUID_EXTENSION = ".liquid"
|
|
|
26
28
|
# `{% javascript %}` blocks are always linted regardless of the section name.
|
|
27
29
|
JS_NAME_FILTER = "td-"
|
|
28
30
|
|
|
31
|
+
# The same policy for CSS: `component-td-card.css` is Theory's, `vendor.css` is
|
|
32
|
+
# not. Stated separately from JS_NAME_FILTER so either can change alone. Liquid
|
|
33
|
+
# `{% stylesheet %}` blocks are always linted regardless of the section name.
|
|
34
|
+
CSS_NAME_FILTER = "td-"
|
|
35
|
+
|
|
29
36
|
ESLINT_SEVERITY = {2: "error", 1: "warning"}
|
|
30
37
|
|
|
31
|
-
#
|
|
32
|
-
|
|
38
|
+
# Stylelint reports a severity string rather than a number, and encodes a CSS
|
|
39
|
+
# syntax error as a rule name rather than a flag.
|
|
40
|
+
STYLELINT_SEVERITIES = ("error", "warning")
|
|
41
|
+
STYLELINT_PARSE_ERROR_RULE = "CssSyntaxError"
|
|
33
42
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
)
|
|
43
|
+
# Theme Check reports LSP-style zero-indexed positions; ESLint and Stylelint are
|
|
44
|
+
# both one-indexed.
|
|
45
|
+
LINE_BASE_OFFSET = 1
|
|
38
46
|
|
|
39
47
|
|
|
40
48
|
class ShopifyLintError(RuntimeError):
|
|
@@ -164,6 +172,22 @@ def run_theme_check(
|
|
|
164
172
|
return reports
|
|
165
173
|
|
|
166
174
|
|
|
175
|
+
def resolve_lint_target(
|
|
176
|
+
relative: str, repo_root: Path, theme_path: Path
|
|
177
|
+
) -> Path | None:
|
|
178
|
+
"""Absolute path for a changed file, or `None` if it cannot be linted."""
|
|
179
|
+
absolute = repo_root / relative
|
|
180
|
+
if not absolute.is_file():
|
|
181
|
+
# Renamed or deleted after being changed on the branch.
|
|
182
|
+
return None
|
|
183
|
+
try:
|
|
184
|
+
absolute.resolve().relative_to(theme_path.resolve())
|
|
185
|
+
except ValueError:
|
|
186
|
+
# Outside the theme root that Theme Check was pointed at.
|
|
187
|
+
return None
|
|
188
|
+
return absolute
|
|
189
|
+
|
|
190
|
+
|
|
167
191
|
def select_js_targets(
|
|
168
192
|
changed_files: set[str], repo_root: Path, theme_path: Path
|
|
169
193
|
) -> list[Path]:
|
|
@@ -190,16 +214,39 @@ def select_js_targets(
|
|
|
190
214
|
else:
|
|
191
215
|
continue
|
|
192
216
|
|
|
193
|
-
absolute = repo_root
|
|
194
|
-
if not
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
217
|
+
absolute = resolve_lint_target(relative, repo_root, theme_path)
|
|
218
|
+
if absolute is not None:
|
|
219
|
+
targets.append(absolute)
|
|
220
|
+
return targets
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def select_css_targets(
|
|
224
|
+
changed_files: set[str], repo_root: Path, theme_path: Path
|
|
225
|
+
) -> list[Path]:
|
|
226
|
+
"""Pick the modified files worth handing to Stylelint.
|
|
227
|
+
|
|
228
|
+
The same policy as `select_js_targets`, one extension along: standalone
|
|
229
|
+
`.css` is filtered to Theory-owned files by name so vendored stylesheets are
|
|
230
|
+
left alone, and every modified `.liquid` file is included regardless of its
|
|
231
|
+
name because the CSS inside a `{% stylesheet %}` block is Theory's either
|
|
232
|
+
way. The bundled mask returns nothing for a file without such a block, so
|
|
233
|
+
there is no need to read them here.
|
|
234
|
+
"""
|
|
235
|
+
targets = []
|
|
236
|
+
for relative in sorted(changed_files):
|
|
237
|
+
if relative.endswith(LIQUID_EXTENSION):
|
|
238
|
+
pass
|
|
239
|
+
elif relative.endswith(CSS_EXTENSIONS):
|
|
240
|
+
if relative.endswith(".min.css"):
|
|
241
|
+
continue
|
|
242
|
+
if CSS_NAME_FILTER not in PurePosixPath(relative).name.lower():
|
|
243
|
+
continue
|
|
244
|
+
else:
|
|
201
245
|
continue
|
|
202
|
-
|
|
246
|
+
|
|
247
|
+
absolute = resolve_lint_target(relative, repo_root, theme_path)
|
|
248
|
+
if absolute is not None:
|
|
249
|
+
targets.append(absolute)
|
|
203
250
|
return targets
|
|
204
251
|
|
|
205
252
|
|
|
@@ -245,6 +292,97 @@ def run_js_lint(
|
|
|
245
292
|
return results
|
|
246
293
|
|
|
247
294
|
|
|
295
|
+
def run_css_lint(
|
|
296
|
+
theme_path: Path, targets: list[Path], runner: Path | None = None
|
|
297
|
+
) -> list[dict[str, Any]]:
|
|
298
|
+
if not targets:
|
|
299
|
+
return []
|
|
300
|
+
|
|
301
|
+
runner_path = runner or STYLELINT_RUNNER
|
|
302
|
+
if not runner_path.is_file():
|
|
303
|
+
raise ShopifyLintError(
|
|
304
|
+
"Theory CSS lint runner is missing; run scripts/setup.sh."
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
payload = json.dumps(
|
|
308
|
+
{"cwd": str(theme_path), "files": [str(target) for target in targets]}
|
|
309
|
+
)
|
|
310
|
+
try:
|
|
311
|
+
result = subprocess.run(
|
|
312
|
+
["node", str(runner_path)],
|
|
313
|
+
input=payload,
|
|
314
|
+
cwd=theme_path,
|
|
315
|
+
capture_output=True,
|
|
316
|
+
text=True,
|
|
317
|
+
check=False,
|
|
318
|
+
)
|
|
319
|
+
except FileNotFoundError as error:
|
|
320
|
+
raise ShopifyLintError("Node.js is not installed or is not on PATH.") from error
|
|
321
|
+
|
|
322
|
+
if result.returncode != 0:
|
|
323
|
+
detail = result.stderr.strip() or result.stdout.strip() or "no output"
|
|
324
|
+
raise ShopifyLintError(f"Theory CSS lint failed: {detail}")
|
|
325
|
+
|
|
326
|
+
try:
|
|
327
|
+
results = json.loads(result.stdout)
|
|
328
|
+
except json.JSONDecodeError as error:
|
|
329
|
+
detail = result.stderr.strip() or result.stdout.strip() or "no output"
|
|
330
|
+
raise ShopifyLintError(f"Theory CSS lint failed: {detail}") from error
|
|
331
|
+
|
|
332
|
+
if not isinstance(results, list):
|
|
333
|
+
raise ShopifyLintError("Theory CSS lint returned an unexpected JSON shape.")
|
|
334
|
+
return results
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def stylelint_check_code(warning: dict[str, Any]) -> str:
|
|
338
|
+
"""Namespace a Stylelint rule so its origin is obvious in the report."""
|
|
339
|
+
rule = warning.get("rule")
|
|
340
|
+
if not rule:
|
|
341
|
+
return "stylelint/unknown"
|
|
342
|
+
if rule == STYLELINT_PARSE_ERROR_RULE:
|
|
343
|
+
return "stylelint/parse-error"
|
|
344
|
+
return rule if "/" in rule else f"stylelint/{rule}"
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def stylelint_severity(warning: dict[str, Any]) -> str:
|
|
348
|
+
"""Map a Stylelint severity.
|
|
349
|
+
|
|
350
|
+
A CSS parse error inside a `{% stylesheet %}` block used to be demoted to
|
|
351
|
+
`info`, on the grounds that masking Liquid out cannot always produce
|
|
352
|
+
parseable CSS and the author had no reasonable way to fix it. Liquid does
|
|
353
|
+
not belong in the block in the first place — Theme Check's
|
|
354
|
+
`StaticStylesheetAndJavascriptTags` reports it as an error — so the fix is
|
|
355
|
+
well defined and a parse error is graded like any other.
|
|
356
|
+
"""
|
|
357
|
+
severity = str(warning.get("severity", ""))
|
|
358
|
+
return severity if severity in STYLELINT_SEVERITIES else "warning"
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def normalize_stylelint_results(results: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
362
|
+
"""Convert the CSS runner's results into the report shape Theme Check emits."""
|
|
363
|
+
reports = []
|
|
364
|
+
for result in results:
|
|
365
|
+
warnings = result.get("warnings") or []
|
|
366
|
+
if not warnings:
|
|
367
|
+
continue
|
|
368
|
+
path = str(result.get("path", ""))
|
|
369
|
+
offenses = []
|
|
370
|
+
for warning in warnings:
|
|
371
|
+
offenses.append(
|
|
372
|
+
{
|
|
373
|
+
"check": stylelint_check_code(warning),
|
|
374
|
+
"severity": stylelint_severity(warning),
|
|
375
|
+
"message": str(warning.get("text", "")),
|
|
376
|
+
"start_row": max(int(warning.get("line", 1)) - LINE_BASE_OFFSET, 0),
|
|
377
|
+
"start_column": max(
|
|
378
|
+
int(warning.get("column", 1)) - LINE_BASE_OFFSET, 0
|
|
379
|
+
),
|
|
380
|
+
}
|
|
381
|
+
)
|
|
382
|
+
reports.append({"path": path, "offenses": offenses})
|
|
383
|
+
return reports
|
|
384
|
+
|
|
385
|
+
|
|
248
386
|
def eslint_check_code(message: dict[str, Any]) -> str:
|
|
249
387
|
"""Namespace an ESLint rule so its origin is obvious in the report."""
|
|
250
388
|
rule = message.get("ruleId")
|
|
@@ -253,16 +391,17 @@ def eslint_check_code(message: dict[str, Any]) -> str:
|
|
|
253
391
|
return rule if "/" in rule else f"eslint/{rule}"
|
|
254
392
|
|
|
255
393
|
|
|
256
|
-
def eslint_severity(message: dict[str, Any]
|
|
257
|
-
"""Map an ESLint severity
|
|
394
|
+
def eslint_severity(message: dict[str, Any]) -> str:
|
|
395
|
+
"""Map an ESLint severity.
|
|
258
396
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
397
|
+
A parse error inside a `{% javascript %}` block used to be demoted to
|
|
398
|
+
`info`, because Liquid control flow that straddles a JavaScript syntax
|
|
399
|
+
boundary cannot be masked into parseable code and the author had no
|
|
400
|
+
reasonable way to fix it. That Liquid is now an offense of its own —
|
|
401
|
+
Theme Check's `StaticStylesheetAndJavascriptTags` reports it as an error — so
|
|
402
|
+
a parse error is graded like any other: either the block still holds Liquid,
|
|
403
|
+
which has to go, or the JavaScript itself is broken.
|
|
263
404
|
"""
|
|
264
|
-
if message.get("fatal") and path.endswith(LIQUID_EXTENSION):
|
|
265
|
-
return "info"
|
|
266
405
|
return ESLINT_SEVERITY.get(message.get("severity"), "warning")
|
|
267
406
|
|
|
268
407
|
|
|
@@ -276,15 +415,11 @@ def normalize_eslint_results(results: list[dict[str, Any]]) -> list[dict[str, An
|
|
|
276
415
|
path = str(result.get("filePath", ""))
|
|
277
416
|
offenses = []
|
|
278
417
|
for message in messages:
|
|
279
|
-
severity = eslint_severity(message, path)
|
|
280
|
-
text = str(message.get("message", ""))
|
|
281
|
-
if severity == "info" and message.get("fatal"):
|
|
282
|
-
text += LIQUID_PARSE_NOTE
|
|
283
418
|
offenses.append(
|
|
284
419
|
{
|
|
285
420
|
"check": eslint_check_code(message),
|
|
286
|
-
"severity":
|
|
287
|
-
"message":
|
|
421
|
+
"severity": eslint_severity(message),
|
|
422
|
+
"message": str(message.get("message", "")),
|
|
288
423
|
"start_row": max(int(message.get("line", 1)) - LINE_BASE_OFFSET, 0),
|
|
289
424
|
"start_column": max(
|
|
290
425
|
int(message.get("column", 1)) - LINE_BASE_OFFSET, 0
|
|
@@ -422,6 +557,12 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
422
557
|
default=bool(os.environ.get("SHOPIFY_LINT_SKIP_JS")),
|
|
423
558
|
help="Skip JavaScript linting and report Theme Check offenses only.",
|
|
424
559
|
)
|
|
560
|
+
parser.add_argument(
|
|
561
|
+
"--skip-css",
|
|
562
|
+
action="store_true",
|
|
563
|
+
default=bool(os.environ.get("SHOPIFY_LINT_SKIP_CSS")),
|
|
564
|
+
help="Skip CSS linting and report the other linters' offenses only.",
|
|
565
|
+
)
|
|
425
566
|
return parser
|
|
426
567
|
|
|
427
568
|
|
|
@@ -439,7 +580,15 @@ def main() -> int:
|
|
|
439
580
|
if not arguments.skip_js:
|
|
440
581
|
targets = select_js_targets(changed_files, repo_root, theme_path)
|
|
441
582
|
js_reports = normalize_eslint_results(run_js_lint(theme_path, targets))
|
|
442
|
-
|
|
583
|
+
css_reports: list[dict[str, Any]] = []
|
|
584
|
+
if not arguments.skip_css:
|
|
585
|
+
css_targets = select_css_targets(changed_files, repo_root, theme_path)
|
|
586
|
+
css_reports = normalize_stylelint_results(
|
|
587
|
+
run_css_lint(theme_path, css_targets)
|
|
588
|
+
)
|
|
589
|
+
reports = merge_reports(
|
|
590
|
+
theme_reports, js_reports, css_reports, repo_root=repo_root
|
|
591
|
+
)
|
|
443
592
|
filtered = filter_reports(reports, changed_files, repo_root)
|
|
444
593
|
except ShopifyLintError as error:
|
|
445
594
|
print(f"shopify-lint: {error}", file=sys.stderr)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Intentionally empty.
|
|
2
|
+
#
|
|
3
|
+
# bin/lint-css.mjs passes this file as Stylelint's `ignorePath`. Naming any
|
|
4
|
+
# path there replaces Stylelint's default lookup for a `.stylelintignore` in
|
|
5
|
+
# the working directory, which is the point: the branch lint is a gate, so a
|
|
6
|
+
# theme must not be able to silence it by adding an ignore file of its own.
|
|
7
|
+
#
|
|
8
|
+
# Do not add patterns here. Scoping is decided by scripts/shopify_lint.py,
|
|
9
|
+
# which passes the runner an explicit list of branch-modified files.
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# stylelint-config-theory
|
|
2
|
+
|
|
3
|
+
Theory Digital's Stylelint configuration for Shopify theme CSS, plus a mask that
|
|
4
|
+
lints the CSS inside Liquid `{% stylesheet %}` blocks.
|
|
5
|
+
|
|
6
|
+
It is bundled with the `shopify-lint` skill and is normally run through that
|
|
7
|
+
skill's branch-scoped runner rather than directly. Like `theme-check-theory` and
|
|
8
|
+
`eslint-plugin-theory`, it ships as TypeScript source: `npm install && npm run
|
|
9
|
+
build` produces the `dist/` that `bin/lint-css.mjs` imports.
|
|
10
|
+
|
|
11
|
+
## Rules
|
|
12
|
+
|
|
13
|
+
No custom rules yet — the configuration is the upstream
|
|
14
|
+
`stylelint-config-standard` set, regraded. The two shared configs it is built
|
|
15
|
+
from split along the line this skill already draws for JavaScript:
|
|
16
|
+
|
|
17
|
+
| Layer | Default | What it covers |
|
|
18
|
+
| --- | --- | --- |
|
|
19
|
+
| `stylelint-config-recommended` | **error** | Unknown properties, at-rules, and pseudo-selectors; duplicate declarations and selectors; invalid values, hex colours, and media queries; empty blocks |
|
|
20
|
+
| `stylelint-config-standard` additions | warning | Notational conventions — `color-function-notation`, `alpha-value-notation`, `length-zero-no-unit`, `shorthand-property-no-redundant-values`, `import-notation`, `value-keyword-case`, the `*-empty-line-before` family |
|
|
21
|
+
|
|
22
|
+
Errors fail the branch lint at its default `--fail-level error`; warnings are
|
|
23
|
+
reported but advisory, matching how the bundled Theme Check rules behave. The
|
|
24
|
+
Stop hooks run at `--fail-level warning`, so warnings block handoff too.
|
|
25
|
+
|
|
26
|
+
The split is derived from `Object.keys(recommended.rules)` at build time rather
|
|
27
|
+
than listed by hand, so a rule promoted into the recommended config becomes an
|
|
28
|
+
error on the next dependency bump with no edit here.
|
|
29
|
+
|
|
30
|
+
Note that Stylelint removed its ~76 purely stylistic rules in v15 and v16, so
|
|
31
|
+
this configuration does not check indentation, quote style, brace placement, or
|
|
32
|
+
hex casing. Formatting is not Stylelint's job any more.
|
|
33
|
+
|
|
34
|
+
### Opt-in rules
|
|
35
|
+
|
|
36
|
+
Shipped disabled, each because it needs a decision that has not been made yet:
|
|
37
|
+
|
|
38
|
+
| Rule | Why it is off |
|
|
39
|
+
| --- | --- |
|
|
40
|
+
| `selector-class-pattern` | Standard's default pattern is kebab-case only and rejects the BEM-ish names Theory writes (`td-card__title--active`). Re-enable with a Theory-specific pattern once that naming is written down. |
|
|
41
|
+
| `selector-id-pattern` | Same pattern, same reason. |
|
|
42
|
+
| `custom-property-pattern` | Same pattern, applied to `--custom-properties`. |
|
|
43
|
+
| `keyframes-name-pattern` | Same pattern, applied to `@keyframes` names. |
|
|
44
|
+
|
|
45
|
+
`no-descending-specificity` is enabled but demoted to a warning. It lives in the
|
|
46
|
+
recommended config, so it would otherwise be an error, and it is too noisy on
|
|
47
|
+
real theme CSS to block a handoff over.
|
|
48
|
+
|
|
49
|
+
## Liquid handling
|
|
50
|
+
|
|
51
|
+
`{% stylesheet %}` blocks are linted in every modified `.liquid` file regardless
|
|
52
|
+
of the file's name, because that CSS is Theory's either way.
|
|
53
|
+
|
|
54
|
+
Stylelint has no preprocessing hook — its `processors` option is experimental and
|
|
55
|
+
only exposes `postprocess` — so extraction happens in `bin/lint-css.mjs`, which
|
|
56
|
+
masks the file and hands the result to `stylelint.lint()` as `code` with the real
|
|
57
|
+
`.liquid` path as `codeFilename`.
|
|
58
|
+
|
|
59
|
+
`maskLiquidStylesheet` is **length-preserving**: it blanks the whole file to
|
|
60
|
+
spaces, keeping newlines at their original indices, then copies back only the
|
|
61
|
+
`{% stylesheet %}` bodies and blanks the Liquid constructs inside them. Every
|
|
62
|
+
line and column reported against the masked source is therefore already correct
|
|
63
|
+
for the original file, with no offset arithmetic anywhere. It is the CSS twin of
|
|
64
|
+
`eslint-plugin-theory/src/liquid/mask.ts`, kept as a separate file so the two
|
|
65
|
+
bundled packages stay independently installable.
|
|
66
|
+
|
|
67
|
+
A file is skipped entirely — the mask returns `null` — when it has no complete
|
|
68
|
+
`{% stylesheet %}` … `{% endstylesheet %}` pair, when every block sits inside a
|
|
69
|
+
`{% comment %}` or `{% raw %}` region, when every block carries an argument
|
|
70
|
+
(`{% stylesheet 'scss' %}`, the pre-2020 Sass form still found in older themes),
|
|
71
|
+
or when every body is blank once masked. So callers can pass every modified
|
|
72
|
+
`.liquid` file without reading it first.
|
|
73
|
+
|
|
74
|
+
### Liquid in a block is an offense, not a case to accommodate
|
|
75
|
+
|
|
76
|
+
Shopify does not render Liquid inside a `{% stylesheet %}` tag: the block's
|
|
77
|
+
contents are concatenated into the theme's compiled CSS verbatim, so
|
|
78
|
+
interpolation found there is already broken theme code. Theme Check's
|
|
79
|
+
`StaticStylesheetAndJavascriptTags` reports it as an **error**, at its exact
|
|
80
|
+
position in the `.liquid` file — see `theme-check-theory/README.md`.
|
|
81
|
+
|
|
82
|
+
That is why this package no longer bends its rules around masked Liquid. A
|
|
83
|
+
Liquid output tag inside a block is still replaced by `0` padded with blanks —
|
|
84
|
+
without a stand-in value the block would collapse into a parse error, hiding
|
|
85
|
+
every real offense after it — but the rules that inspect the shape of a value or
|
|
86
|
+
an at-rule prelude stay on. `margin: {{ s.gap }}px` masks to `margin: 0 px`
|
|
87
|
+
and is reported as an unknown value; the fix is the same fix the Liquid offense
|
|
88
|
+
asks for. There is one config, and a `{% stylesheet %}` block is graded exactly
|
|
89
|
+
as a standalone `.css` file is.
|
|
90
|
+
|
|
91
|
+
For the same reason a CSS syntax error inside `.liquid` is a plain error. It used
|
|
92
|
+
to be demoted to `info` — masking cannot always yield parseable CSS, and the
|
|
93
|
+
author had no defined fix — but removing the Liquid is now that fix.
|
|
94
|
+
|
|
95
|
+
`{% comment %}` and `{% raw %}` bodies *inside* a block are still blanked before
|
|
96
|
+
the CSS is parsed, so their contents are never mistaken for CSS. The tags
|
|
97
|
+
themselves are reported by the Theme Check check: Liquid comments are not
|
|
98
|
+
comments here either.
|
|
99
|
+
|
|
100
|
+
## The lint is a gate
|
|
101
|
+
|
|
102
|
+
No Stylelint configuration is written to the project root and the theme's own
|
|
103
|
+
configuration is never consulted. `bin/lint-css.mjs` passes an inline `config`
|
|
104
|
+
object, which stops Stylelint searching for a `.stylelintrc`, and names this
|
|
105
|
+
package's comment-only `.stylelintignore` as `ignorePath`, which replaces
|
|
106
|
+
Stylelint's default lookup for a `.stylelintignore` in the working directory.
|
|
107
|
+
Without that second step a theme could silence the lint with an ignore file, and
|
|
108
|
+
Stylelint reports an ignored file as no result at all rather than as a marker —
|
|
109
|
+
so the runner also asserts it got exactly one result per file.
|
|
110
|
+
|
|
111
|
+
Override the rules per project with `SHOPIFY_LINT_STYLELINT_CONFIG=<path>`
|
|
112
|
+
pointing at a module that exports a `createConfig` function.
|
|
113
|
+
|
|
114
|
+
## Development
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
npm install
|
|
118
|
+
npm run build # tsc -p tsconfig.build.json -> dist/
|
|
119
|
+
npm test # vitest run
|
|
120
|
+
npm run test:watch
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
`src/build.test.ts` typechecks under the build config, because
|
|
124
|
+
`scripts/setup.sh` runs `npm run build` and a type error there breaks setup for
|
|
125
|
+
every consumer of the skill.
|
|
126
|
+
|
|
127
|
+
## Adding a rule
|
|
128
|
+
|
|
129
|
+
The package has no `src/rules/` directory yet. To add the first custom rule:
|
|
130
|
+
|
|
131
|
+
1. Create `src/rules/<name>.ts` as a Stylelint plugin rule
|
|
132
|
+
(`stylelint.createPlugin('theory/<name>', …)`).
|
|
133
|
+
2. Register it in a `plugin` export in `src/index.ts`, and add `plugins` to the
|
|
134
|
+
object `createConfig` returns in `src/config.ts`.
|
|
135
|
+
3. Add `src/rules/<name>.test.ts` linting real CSS through `stylelint.lint()`.
|
|
136
|
+
4. Give it a severity in `src/config.ts` — decide explicitly whether it is a
|
|
137
|
+
correctness rule (error) or a convention (warning). There is no Liquid
|
|
138
|
+
variant to configure: masked Liquid only appears in a block that is already
|
|
139
|
+
failing the lint.
|
|
140
|
+
5. Document it in the table above.
|
|
141
|
+
|
|
142
|
+
Nothing outside this package needs changing: `bin/lint-css.mjs` reads whatever
|
|
143
|
+
`createConfig` returns, and `scripts/shopify_lint.py` already passes a
|
|
144
|
+
`theory/`-namespaced rule through as its own check code.
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* CSS lint runner spawned by scripts/shopify_lint.py.
|
|
4
|
+
*
|
|
5
|
+
* Reads `{"cwd": "<theme root>", "files": ["<absolute path>", ...]}` on stdin
|
|
6
|
+
* and writes an array of per-file results to stdout:
|
|
7
|
+
*
|
|
8
|
+
* [{ "path", "warnings": [{ line, column, rule, severity, text }] }, ...]
|
|
9
|
+
*
|
|
10
|
+
* Exit codes:
|
|
11
|
+
* 0 results were produced (however many problems they contain)
|
|
12
|
+
* 1 the lint could not run
|
|
13
|
+
*
|
|
14
|
+
* Lint problems never change the exit code — the branch runner owns the
|
|
15
|
+
* fail-level policy.
|
|
16
|
+
*
|
|
17
|
+
* Unlike the JavaScript runner, this one does not emit Stylelint's native
|
|
18
|
+
* results. Two reasons, both load-bearing:
|
|
19
|
+
*
|
|
20
|
+
* 1. A `LintResult` carries an internal `_postcssResult`, which is a circular
|
|
21
|
+
* structure — `JSON.stringify` on it throws.
|
|
22
|
+
* 2. A `parseErrors` entry serialises its PostCSS node, and that embeds the
|
|
23
|
+
* entire source text of the file in the payload.
|
|
24
|
+
*
|
|
25
|
+
* So each result is projected down to the fields the Python side reads, with
|
|
26
|
+
* `parseErrors` folded into `warnings` (Stylelint's own formatters do the same;
|
|
27
|
+
* its `json` formatter does not, which is why a caller reading only `warnings`
|
|
28
|
+
* would silently drop them).
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { readFile } from 'node:fs/promises';
|
|
32
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
33
|
+
import { dirname, join } from 'node:path';
|
|
34
|
+
|
|
35
|
+
const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Naming any path as Stylelint's `ignorePath` replaces its default lookup for a
|
|
39
|
+
* `.stylelintignore` in the working directory. That is deliberate: the branch
|
|
40
|
+
* lint is a gate, so a theme must not be able to silence it with an ignore
|
|
41
|
+
* file. The file this points at is committed and contains only comments.
|
|
42
|
+
*/
|
|
43
|
+
const IGNORE_PATH = join(PACKAGE_ROOT, '.stylelintignore');
|
|
44
|
+
|
|
45
|
+
const LIQUID_EXTENSION = '.liquid';
|
|
46
|
+
|
|
47
|
+
function fail(message) {
|
|
48
|
+
process.stderr.write(`theory css lint: ${message}\n`);
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function readStdin() {
|
|
53
|
+
const chunks = [];
|
|
54
|
+
for await (const chunk of process.stdin) {
|
|
55
|
+
chunks.push(chunk);
|
|
56
|
+
}
|
|
57
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function loadConfig(createConfig) {
|
|
61
|
+
const override = process.env.SHOPIFY_LINT_STYLELINT_CONFIG;
|
|
62
|
+
if (!override) {
|
|
63
|
+
return createConfig;
|
|
64
|
+
}
|
|
65
|
+
const imported = await import(pathToFileURL(override).href);
|
|
66
|
+
const custom = imported.createConfig ?? imported.default;
|
|
67
|
+
if (typeof custom !== 'function') {
|
|
68
|
+
fail(`config at ${override} must export a createConfig function or a default function.`);
|
|
69
|
+
}
|
|
70
|
+
return custom;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Reduce one Stylelint result to the shape the Python side consumes.
|
|
75
|
+
*
|
|
76
|
+
* `parseErrors` are PostCSS warnings and carry no `rule`, so they are labelled
|
|
77
|
+
* `CssSyntaxError` to join the syntax errors Stylelint already reports that way.
|
|
78
|
+
*/
|
|
79
|
+
function project(path, result) {
|
|
80
|
+
const warnings = (result.warnings ?? []).map((warning) => ({
|
|
81
|
+
line: warning.line,
|
|
82
|
+
column: warning.column,
|
|
83
|
+
rule: warning.rule,
|
|
84
|
+
severity: warning.severity,
|
|
85
|
+
text: warning.text,
|
|
86
|
+
}));
|
|
87
|
+
|
|
88
|
+
for (const parseError of result.parseErrors ?? []) {
|
|
89
|
+
warnings.push({
|
|
90
|
+
line: parseError.line,
|
|
91
|
+
column: parseError.column,
|
|
92
|
+
rule: 'CssSyntaxError',
|
|
93
|
+
severity: 'error',
|
|
94
|
+
text: parseError.text,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return { path, warnings };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function main() {
|
|
102
|
+
let payload;
|
|
103
|
+
try {
|
|
104
|
+
payload = JSON.parse(await readStdin());
|
|
105
|
+
} catch (error) {
|
|
106
|
+
fail(`could not parse the request on stdin: ${error.message}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const files = Array.isArray(payload?.files) ? payload.files : [];
|
|
110
|
+
if (files.length === 0) {
|
|
111
|
+
process.stdout.write('[]');
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
let stylelint;
|
|
116
|
+
let createConfig;
|
|
117
|
+
let maskLiquidStylesheet;
|
|
118
|
+
try {
|
|
119
|
+
stylelint = (await import('stylelint')).default;
|
|
120
|
+
({ createConfig } = await import('../dist/config.js'));
|
|
121
|
+
({ maskLiquidStylesheet } = await import('../dist/index.js'));
|
|
122
|
+
} catch (error) {
|
|
123
|
+
fail(
|
|
124
|
+
'dependencies are not installed or the package is not built. ' +
|
|
125
|
+
`Run: bash <skill>/scripts/setup.sh (${error.message})`,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const buildConfig = await loadConfig(createConfig);
|
|
130
|
+
const cwd = payload.cwd ?? process.cwd();
|
|
131
|
+
// One config for both sources: a `{% stylesheet %}` block is graded exactly as
|
|
132
|
+
// a standalone `.css` file is.
|
|
133
|
+
const config = buildConfig();
|
|
134
|
+
|
|
135
|
+
const results = [];
|
|
136
|
+
for (const file of files) {
|
|
137
|
+
const isLiquid = file.toLowerCase().endsWith(LIQUID_EXTENSION);
|
|
138
|
+
|
|
139
|
+
let code;
|
|
140
|
+
try {
|
|
141
|
+
code = await readFile(file, 'utf8');
|
|
142
|
+
} catch {
|
|
143
|
+
// Deleted between Git listing it and us reading it; the branch runner
|
|
144
|
+
// already tolerates that for the other linters.
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (isLiquid) {
|
|
149
|
+
code = maskLiquidStylesheet(code);
|
|
150
|
+
// No `{% stylesheet %}` block worth linting, so the file is not CSS at
|
|
151
|
+
// all — callers can pass every modified `.liquid` file blindly.
|
|
152
|
+
if (code === null) {
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
let linted;
|
|
158
|
+
try {
|
|
159
|
+
linted = await stylelint.lint({
|
|
160
|
+
code,
|
|
161
|
+
codeFilename: file,
|
|
162
|
+
config,
|
|
163
|
+
cwd,
|
|
164
|
+
// Stylelint applies its ignore rules to `codeFilename` too, and returns
|
|
165
|
+
// an empty result set rather than a marker when one matches. Both of
|
|
166
|
+
// these are needed to keep that from silencing the gate; the assertion
|
|
167
|
+
// below catches anything they miss.
|
|
168
|
+
ignorePath: IGNORE_PATH,
|
|
169
|
+
disableDefaultIgnores: true,
|
|
170
|
+
quietDeprecationWarnings: true,
|
|
171
|
+
});
|
|
172
|
+
} catch (error) {
|
|
173
|
+
// `lint()` resolves for CSS syntax errors and rejects only when it could
|
|
174
|
+
// not run at all — a broken config, an unresolvable dependency.
|
|
175
|
+
fail(`could not lint ${file}: ${error.message}`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (linted.results.length !== 1) {
|
|
179
|
+
fail(
|
|
180
|
+
`expected one result for ${file}, got ${linted.results.length}. ` +
|
|
181
|
+
'Stylelint ignored the file, which would report it as clean.',
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const [result] = linted.results;
|
|
186
|
+
if (result.ignored) {
|
|
187
|
+
fail(`${file} was ignored by Stylelint, which would report it as clean.`);
|
|
188
|
+
}
|
|
189
|
+
if (result.invalidOptionWarnings?.length) {
|
|
190
|
+
// The bundled config is broken. That is a setup error, not a finding
|
|
191
|
+
// about the theme, so it must not be reported as a lint offense.
|
|
192
|
+
fail(
|
|
193
|
+
'the bundled Stylelint configuration is invalid: ' +
|
|
194
|
+
result.invalidOptionWarnings.map((warning) => warning.text).join('; '),
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
results.push(project(file, result));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
process.stdout.write(JSON.stringify(results));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
main().catch((error) => {
|
|
205
|
+
fail(error?.stack ?? String(error));
|
|
206
|
+
});
|