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
@@ -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
- # Theme Check reports LSP-style zero-indexed positions; ESLint is one-indexed.
32
- LINE_BASE_OFFSET = 1
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
- LIQUID_PARSE_NOTE = (
35
- " (Liquid interpolation is masked before parsing; the reported position "
36
- "may be approximate.)"
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 / relative
194
- if not absolute.is_file():
195
- # Renamed or deleted after being changed on the branch.
196
- continue
197
- try:
198
- absolute.resolve().relative_to(theme_path.resolve())
199
- except ValueError:
200
- # Outside the theme root that Theme Check was pointed at.
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
- targets.append(absolute)
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], path: str) -> str:
257
- """Map an ESLint severity, demoting parse errors inside Liquid.
394
+ def eslint_severity(message: dict[str, Any]) -> str:
395
+ """Map an ESLint severity.
258
396
 
259
- Masking Liquid out of a `{% javascript %}` block cannot always produce
260
- valid JavaScript — control flow that straddles a syntax boundary defeats
261
- it. Reporting those at `info` keeps them visible without ever failing a run
262
- that the author has no reasonable way to fix.
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": severity,
287
- "message": text,
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
- reports = merge_reports(theme_reports, js_reports, repo_root=repo_root)
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
+ });