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
@@ -0,0 +1,540 @@
1
+ #!/usr/bin/env python3
2
+ """Audit branch changes to vendor files for TD CHANGE delimiter coverage.
3
+
4
+ A vendor file is any changed file whose basename does not contain the custom
5
+ prefix (`td-` by default). Every modification to such a file must sit inside a
6
+ pair of TD CHANGE delimiter comments. This script reports, per file, which
7
+ changed line ranges are already inside a delimiter block at any nesting depth
8
+ and which still need to be wrapped.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import json
15
+ import os
16
+ import re
17
+ import subprocess
18
+ import sys
19
+
20
+
21
+ DEFAULT_BASE_CANDIDATES = ("main", "origin/main", "master", "origin/master")
22
+
23
+ START_TEXT = "------------TD CHANGE-----------"
24
+ END_TEXT = "----------END TD CHANGE---------"
25
+
26
+ # `END` is checked before `START`, because an end marker also matches the start
27
+ # pattern. Separators are loose so hand-typed variants still count as coverage.
28
+ END_MARKER = re.compile(r"END[\s_-]*TD[\s_-]*CHANGE", re.IGNORECASE)
29
+ START_MARKER = re.compile(r"TD[\s_-]*CHANGE", re.IGNORECASE)
30
+
31
+ HUNK_HEADER = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@")
32
+
33
+ # Comment styles keyed by extension. `None` means the format cannot carry
34
+ # comments at all, so the change has to be documented instead of delimited.
35
+ LINE_COMMENT = "line"
36
+ BLOCK_COMMENT = "block"
37
+
38
+ COMMENT_STYLES: dict[str, dict[str, str] | None] = {
39
+ ".liquid": {"kind": BLOCK_COMMENT, "open": "{% comment %}", "close": "{% endcomment %}", "language": "liquid"},
40
+ ".js": {"kind": LINE_COMMENT, "open": "//", "language": "javascript"},
41
+ ".mjs": {"kind": LINE_COMMENT, "open": "//", "language": "javascript"},
42
+ ".cjs": {"kind": LINE_COMMENT, "open": "//", "language": "javascript"},
43
+ ".ts": {"kind": LINE_COMMENT, "open": "//", "language": "typescript"},
44
+ ".css": {"kind": BLOCK_COMMENT, "open": "/*", "close": "*/", "language": "css"},
45
+ ".scss": {"kind": LINE_COMMENT, "open": "//", "language": "scss"},
46
+ ".sass": {"kind": LINE_COMMENT, "open": "//", "language": "sass"},
47
+ ".html": {"kind": BLOCK_COMMENT, "open": "<!--", "close": "-->", "language": "html"},
48
+ ".svg": {"kind": BLOCK_COMMENT, "open": "<!--", "close": "-->", "language": "svg"},
49
+ ".xml": {"kind": BLOCK_COMMENT, "open": "<!--", "close": "-->", "language": "xml"},
50
+ ".md": {"kind": BLOCK_COMMENT, "open": "<!--", "close": "-->", "language": "markdown"},
51
+ ".yml": {"kind": LINE_COMMENT, "open": "#", "language": "yaml"},
52
+ ".yaml": {"kind": LINE_COMMENT, "open": "#", "language": "yaml"},
53
+ ".sh": {"kind": LINE_COMMENT, "open": "#", "language": "shell"},
54
+ ".json": None,
55
+ ".jsonc": None,
56
+ }
57
+
58
+ # Regions inside a `.liquid` file that use a different comment syntax than the
59
+ # surrounding Liquid markup, or that cannot be commented at all.
60
+ LIQUID_REGIONS = (
61
+ ("schema", re.compile(r"\{%-?\s*schema\s*-?%\}"), re.compile(r"\{%-?\s*endschema\s*-?%\}"), None),
62
+ ("javascript", re.compile(r"\{%-?\s*javascript\s*-?%\}"), re.compile(r"\{%-?\s*endjavascript\s*-?%\}"), ".js"),
63
+ ("stylesheet", re.compile(r"\{%-?\s*stylesheet[^%]*-?%\}"), re.compile(r"\{%-?\s*endstylesheet\s*-?%\}"), ".css"),
64
+ ("script tag", re.compile(r"<script(?![^>]*\bsrc=)[^>]*>"), re.compile(r"</script>"), ".js"),
65
+ ("style tag", re.compile(r"<style[^>]*>"), re.compile(r"</style>"), ".css"),
66
+ )
67
+
68
+ # Paths that are project tooling or documentation rather than theme vendor code.
69
+ IGNORED_PREFIXES = (".agents/", ".claude/", ".codex/", ".github/", ".vscode/", "docs/", "node_modules/")
70
+ IGNORED_BASENAMES = ("package-lock.json", "yarn.lock", "pnpm-lock.yaml", ".gitignore", "README.md", "AGENTS.md", "CLAUDE.md")
71
+
72
+
73
+ def run_git(*args: str) -> str:
74
+ completed = subprocess.run(
75
+ ["git", *args],
76
+ check=True,
77
+ capture_output=True,
78
+ text=True,
79
+ )
80
+ return completed.stdout.strip()
81
+
82
+
83
+ def ref_exists(ref: str) -> bool:
84
+ completed = subprocess.run(
85
+ ["git", "rev-parse", "--verify", f"{ref}^{{commit}}"],
86
+ capture_output=True,
87
+ text=True,
88
+ )
89
+ return completed.returncode == 0
90
+
91
+
92
+ def resolve_base_ref(explicit_base: str | None) -> str:
93
+ if explicit_base:
94
+ if not ref_exists(explicit_base):
95
+ raise SystemExit(f"Base ref not found: {explicit_base}")
96
+ return explicit_base
97
+
98
+ for candidate in DEFAULT_BASE_CANDIDATES:
99
+ if ref_exists(candidate):
100
+ return candidate
101
+
102
+ searched = ", ".join(DEFAULT_BASE_CANDIDATES)
103
+ raise SystemExit(f"No base ref found. Tried: {searched}")
104
+
105
+
106
+ def is_ignored(path: str) -> bool:
107
+ if path.startswith(IGNORED_PREFIXES):
108
+ return True
109
+ return os.path.basename(path) in IGNORED_BASENAMES
110
+
111
+
112
+ def is_custom(path: str, prefixes: tuple[str, ...]) -> bool:
113
+ """A file is custom (not vendor) when its basename contains a custom prefix."""
114
+ basename = os.path.basename(path)
115
+ return any(prefix in basename for prefix in prefixes)
116
+
117
+
118
+ def comment_style(path: str) -> dict[str, str] | None:
119
+ _, ext = os.path.splitext(path)
120
+ return COMMENT_STYLES.get(ext.lower())
121
+
122
+
123
+ def has_known_extension(path: str) -> bool:
124
+ _, ext = os.path.splitext(path)
125
+ return ext.lower() in COMMENT_STYLES
126
+
127
+
128
+ def delimiters(style: dict[str, str]) -> tuple[str, str]:
129
+ """Render the start/end delimiter lines for a comment style."""
130
+ if style["kind"] == LINE_COMMENT:
131
+ return f"{style['open']} {START_TEXT}", f"{style['open']} {END_TEXT}"
132
+ return (
133
+ f"{style['open']} {START_TEXT} {style['close']}",
134
+ f"{style['open']} {END_TEXT} {style['close']}",
135
+ )
136
+
137
+
138
+ def parse_hunk_ranges(diff_output: str) -> list[tuple[int, int]]:
139
+ """Extract post-image line ranges from a `-U0` diff.
140
+
141
+ A pure deletion (`+c,0`) is reported as the single line `c`, the line the
142
+ removed content used to follow, so its surroundings are still checked for
143
+ delimiter coverage.
144
+ """
145
+ ranges: list[tuple[int, int]] = []
146
+ for line in diff_output.splitlines():
147
+ match = HUNK_HEADER.match(line)
148
+ if not match:
149
+ continue
150
+ start = int(match.group(1))
151
+ count = 1 if match.group(2) is None else int(match.group(2))
152
+ if count == 0:
153
+ ranges.append((max(start, 1), max(start, 1)))
154
+ continue
155
+ ranges.append((start, start + count - 1))
156
+ return ranges
157
+
158
+
159
+ def scan_markers(lines: list[str]) -> dict[str, object]:
160
+ """Walk a file and record which lines sit inside a TD CHANGE block.
161
+
162
+ Nesting is tracked with a depth counter, so a change enclosed by an outer
163
+ block counts as covered even when an inner block sits beside it. Marker
164
+ lines themselves count as covered.
165
+ """
166
+ covered: set[int] = set()
167
+ blocks: list[dict[str, int]] = []
168
+ unclosed: list[int] = []
169
+ unmatched_ends: list[int] = []
170
+ open_starts: list[int] = []
171
+
172
+ for index, line in enumerate(lines, start=1):
173
+ if END_MARKER.search(line):
174
+ if open_starts:
175
+ start_line = open_starts.pop()
176
+ blocks.append({"start": start_line, "end": index, "depth": len(open_starts) + 1})
177
+ else:
178
+ unmatched_ends.append(index)
179
+ covered.add(index)
180
+ continue
181
+
182
+ if START_MARKER.search(line):
183
+ open_starts.append(index)
184
+ covered.add(index)
185
+ continue
186
+
187
+ if open_starts:
188
+ covered.add(index)
189
+
190
+ unclosed = sorted(open_starts)
191
+ return {
192
+ "covered": covered,
193
+ "blocks": sorted(blocks, key=lambda block: block["start"]),
194
+ "unclosed_starts": unclosed,
195
+ "unmatched_ends": unmatched_ends,
196
+ }
197
+
198
+
199
+ def scan_liquid_regions(lines: list[str]) -> list[dict[str, object]]:
200
+ """Locate embedded regions in a Liquid file that need a different comment syntax."""
201
+ regions: list[dict[str, object]] = []
202
+ open_region: dict[str, object] | None = None
203
+
204
+ for index, line in enumerate(lines, start=1):
205
+ if open_region is not None:
206
+ if open_region["_end"].search(line): # type: ignore[union-attr]
207
+ open_region["end"] = index
208
+ del open_region["_end"]
209
+ regions.append(open_region)
210
+ open_region = None
211
+ continue
212
+
213
+ for name, start_pattern, end_pattern, ext in LIQUID_REGIONS:
214
+ if not start_pattern.search(line):
215
+ continue
216
+ if end_pattern.search(line):
217
+ # Opened and closed on one line; nothing spans into later lines.
218
+ regions.append({"name": name, "start": index, "end": index, "extension": ext})
219
+ else:
220
+ open_region = {"name": name, "start": index, "end": 0, "extension": ext, "_end": end_pattern}
221
+ break
222
+
223
+ if open_region is not None:
224
+ open_region["end"] = len(lines)
225
+ del open_region["_end"]
226
+ regions.append(open_region)
227
+
228
+ return regions
229
+
230
+
231
+ def region_for_range(regions: list[dict[str, object]], start: int, end: int) -> dict[str, object] | None:
232
+ for region in regions:
233
+ if start <= region["end"] and end >= region["start"]: # type: ignore[operator]
234
+ return region
235
+ return None
236
+
237
+
238
+ def coverage_for_range(covered: set[int], start: int, end: int) -> str:
239
+ total = end - start + 1
240
+ inside = sum(1 for line in range(start, end + 1) if line in covered)
241
+ if inside == 0:
242
+ return "uncovered"
243
+ if inside == total:
244
+ return "covered"
245
+ return "partial"
246
+
247
+
248
+ def audit_file(path: str, merge_base: str, prefixes: tuple[str, ...], status: str) -> dict[str, object]:
249
+ """Classify one changed file and, for vendor edits, check delimiter coverage."""
250
+ entry: dict[str, object] = {"path": path, "status": status}
251
+
252
+ if is_ignored(path):
253
+ entry["category"] = "ignored"
254
+ return entry
255
+
256
+ if is_custom(path, prefixes):
257
+ entry["category"] = "custom"
258
+ return entry
259
+
260
+ if status == "D":
261
+ entry["category"] = "vendor_deleted"
262
+ return entry
263
+
264
+ if status == "A":
265
+ entry["category"] = "vendor_added"
266
+ return entry
267
+
268
+ style = comment_style(path)
269
+ if style is None:
270
+ entry["category"] = "not_delimitable"
271
+ entry["reason"] = (
272
+ "JSON does not support comments" if has_known_extension(path) else "unrecognized file type"
273
+ )
274
+ return entry
275
+
276
+ entry["category"] = "vendor_modified"
277
+ entry["language"] = style["language"]
278
+ start_delimiter, end_delimiter = delimiters(style)
279
+ entry["start_delimiter"] = start_delimiter
280
+ entry["end_delimiter"] = end_delimiter
281
+
282
+ try:
283
+ with open(path, encoding="utf-8") as handle:
284
+ lines = handle.read().splitlines()
285
+ except (OSError, UnicodeDecodeError) as error:
286
+ entry["category"] = "unreadable"
287
+ entry["reason"] = str(error)
288
+ return entry
289
+
290
+ diff = run_git("diff", "-U0", merge_base, "--", path)
291
+ ranges = parse_hunk_ranges(diff)
292
+ markers = scan_markers(lines)
293
+ covered: set[int] = markers["covered"] # type: ignore[assignment]
294
+ regions = scan_liquid_regions(lines) if path.lower().endswith(".liquid") else []
295
+
296
+ hunks: list[dict[str, object]] = []
297
+ for start, end in ranges:
298
+ hunk: dict[str, object] = {
299
+ "start": start,
300
+ "end": end,
301
+ "coverage": coverage_for_range(covered, start, end),
302
+ }
303
+ region = region_for_range(regions, start, end)
304
+ if region is not None:
305
+ hunk["region"] = region["name"]
306
+ region_extension = region["extension"]
307
+ if region_extension is None:
308
+ hunk["delimitable"] = False
309
+ else:
310
+ region_style = COMMENT_STYLES[region_extension]
311
+ assert region_style is not None
312
+ region_start, region_end = delimiters(region_style)
313
+ hunk["start_delimiter"] = region_start
314
+ hunk["end_delimiter"] = region_end
315
+ hunks.append(hunk)
316
+
317
+ entry["hunks"] = hunks
318
+ entry["blocks"] = markers["blocks"]
319
+ entry["unclosed_starts"] = markers["unclosed_starts"]
320
+ entry["unmatched_ends"] = markers["unmatched_ends"]
321
+ # A hunk in a region that cannot carry comments is never actionable here, so
322
+ # it does not count as needing delimiters — it has to be documented instead.
323
+ entry["needs_delimiters"] = any(
324
+ hunk["coverage"] != "covered" and hunk.get("delimitable") is not False for hunk in hunks
325
+ )
326
+ entry["undelimitable_hunks"] = [
327
+ hunk for hunk in hunks if hunk["coverage"] != "covered" and hunk.get("delimitable") is False
328
+ ]
329
+ entry["malformed"] = bool(markers["unclosed_starts"] or markers["unmatched_ends"])
330
+ return entry
331
+
332
+
333
+ def parse_name_status(output: str) -> list[dict[str, str]]:
334
+ files: list[dict[str, str]] = []
335
+ for line in output.splitlines():
336
+ if not line:
337
+ continue
338
+ parts = line.split("\t")
339
+ status = parts[0]
340
+
341
+ if status.startswith("R") and len(parts) >= 3:
342
+ files.append({"status": "M", "path": parts[2], "old_path": parts[1]})
343
+ continue
344
+
345
+ path = parts[1] if len(parts) > 1 else ""
346
+ files.append({"status": status[0], "path": path, "old_path": ""})
347
+ return files
348
+
349
+
350
+ def build_report(base_ref: str, prefixes: tuple[str, ...], paths: list[str]) -> dict[str, object]:
351
+ current_branch = run_git("branch", "--show-current")
352
+ merge_base = run_git("merge-base", base_ref, "HEAD")
353
+
354
+ diff_args = ["diff", "--name-status", "--find-renames", merge_base]
355
+ if paths:
356
+ diff_args += ["--", *paths]
357
+ changed = parse_name_status(run_git(*diff_args))
358
+
359
+ untracked_args = ["ls-files", "--others", "--exclude-standard"]
360
+ if paths:
361
+ untracked_args += ["--", *paths]
362
+ tracked_paths = {item["path"] for item in changed}
363
+ for path in run_git(*untracked_args).splitlines():
364
+ if path and path not in tracked_paths:
365
+ changed.append({"status": "A", "path": path, "old_path": ""})
366
+
367
+ entries = [audit_file(item["path"], merge_base, prefixes, item["status"]) for item in changed]
368
+
369
+ return {
370
+ "current_branch": current_branch,
371
+ "base_ref": base_ref,
372
+ "merge_base": merge_base,
373
+ "custom_prefixes": list(prefixes),
374
+ "files": entries,
375
+ }
376
+
377
+
378
+ def describe_hunk(hunk: dict[str, object]) -> str:
379
+ start, end = hunk["start"], hunk["end"]
380
+ location = f"{start}" if start == end else f"{start}-{end}"
381
+ if hunk.get("region"):
382
+ location += f" (inside {hunk['region']})"
383
+ if hunk.get("delimitable") is False:
384
+ location += " — cannot be commented"
385
+ if hunk["coverage"] == "partial":
386
+ location += " — partially delimited"
387
+ return location
388
+
389
+
390
+ def print_markdown(report: dict[str, object]) -> None:
391
+ files: list[dict[str, object]] = report["files"] # type: ignore[assignment]
392
+ vendor = [entry for entry in files if entry["category"] == "vendor_modified"]
393
+ needs = [entry for entry in vendor if entry.get("needs_delimiters")]
394
+ clean = [
395
+ entry
396
+ for entry in vendor
397
+ if not entry.get("needs_delimiters") and not entry.get("undelimitable_hunks")
398
+ ]
399
+ malformed = [entry for entry in vendor if entry.get("malformed")]
400
+ undelimitable = [entry for entry in files if entry["category"] == "not_delimitable"]
401
+ undelimitable_hunks = [entry for entry in vendor if entry.get("undelimitable_hunks")]
402
+ added = [entry for entry in files if entry["category"] == "vendor_added"]
403
+ deleted = [entry for entry in files if entry["category"] == "vendor_deleted"]
404
+ unreadable = [entry for entry in files if entry["category"] == "unreadable"]
405
+
406
+ print("# Vendor Change Audit")
407
+ print()
408
+ print(f"- Current branch: `{report['current_branch']}`")
409
+ print(f"- Base ref: `{report['base_ref']}`")
410
+ print(f"- Merge base: `{report['merge_base']}`")
411
+ print(f"- Custom prefixes: {', '.join(f'`{prefix}`' for prefix in report['custom_prefixes'])}")
412
+ print(f"- Vendor files modified: {len(vendor)} ({len(needs)} needing delimiters)")
413
+ print()
414
+
415
+ if needs:
416
+ print("## Needs delimiters")
417
+ print()
418
+ for entry in needs:
419
+ print(f"### `{entry['path']}` ({entry['language']})")
420
+ print()
421
+ print(f"- Start: `{entry['start_delimiter']}`")
422
+ print(f"- End: `{entry['end_delimiter']}`")
423
+ open_hunks = [
424
+ hunk
425
+ for hunk in entry["hunks"] # type: ignore[union-attr]
426
+ if hunk["coverage"] != "covered" and hunk.get("delimitable") is not False
427
+ ]
428
+ for hunk in open_hunks:
429
+ extra = ""
430
+ if hunk.get("start_delimiter"):
431
+ extra = f" — use `{hunk['start_delimiter']}` / `{hunk['end_delimiter']}`"
432
+ print(f"- Lines {describe_hunk(hunk)}{extra}")
433
+ print()
434
+
435
+ if clean:
436
+ print("## Already delimited")
437
+ print()
438
+ for entry in clean:
439
+ count = len(entry["hunks"]) # type: ignore[arg-type]
440
+ blocks = len(entry["blocks"]) # type: ignore[arg-type]
441
+ print(f"- `{entry['path']}` — {count} changed hunk(s) inside {blocks} TD CHANGE block(s)")
442
+ print()
443
+
444
+ if malformed:
445
+ print("## Malformed markers")
446
+ print()
447
+ for entry in malformed:
448
+ for line in entry["unclosed_starts"]: # type: ignore[union-attr]
449
+ print(f"- `{entry['path']}:{line}` — TD CHANGE opened without an END TD CHANGE")
450
+ for line in entry["unmatched_ends"]: # type: ignore[union-attr]
451
+ print(f"- `{entry['path']}:{line}` — END TD CHANGE without a matching TD CHANGE")
452
+ print()
453
+
454
+ if undelimitable or undelimitable_hunks:
455
+ print("## Cannot be delimited")
456
+ print()
457
+ print("Delimiters are impossible here. Document these in `docs/changes.md` with reapply steps.")
458
+ print()
459
+ for entry in undelimitable:
460
+ print(f"- `{entry['path']}` — {entry['reason']}")
461
+ for entry in undelimitable_hunks:
462
+ for hunk in entry["undelimitable_hunks"]: # type: ignore[union-attr]
463
+ location = hunk["start"] if hunk["start"] == hunk["end"] else f"{hunk['start']}-{hunk['end']}"
464
+ print(f"- `{entry['path']}` lines {location} — inside {hunk['region']}")
465
+ print()
466
+
467
+ if added or deleted:
468
+ print("## Other vendor-path changes")
469
+ print()
470
+ for entry in added:
471
+ print(f"- `{entry['path']}` — new file without a custom prefix; rename it or justify it")
472
+ for entry in deleted:
473
+ print(f"- `{entry['path']}` — vendor file deleted; confirm this is intentional")
474
+ print()
475
+
476
+ if unreadable:
477
+ print("## Unreadable")
478
+ print()
479
+ for entry in unreadable:
480
+ print(f"- `{entry['path']}` — {entry['reason']}")
481
+ print()
482
+
483
+ if not vendor and not undelimitable and not added and not deleted:
484
+ print("No vendor files changed on this branch.")
485
+
486
+
487
+ def main() -> int:
488
+ parser = argparse.ArgumentParser(
489
+ description="Audit vendor file changes on this branch for TD CHANGE delimiter coverage."
490
+ )
491
+ parser.add_argument(
492
+ "paths",
493
+ nargs="*",
494
+ help="Optional path filters. Defaults to every changed file on the branch.",
495
+ )
496
+ parser.add_argument(
497
+ "--base",
498
+ help="Explicit base ref to compare against. Defaults to main/origin-main or master/origin-master fallback.",
499
+ )
500
+ parser.add_argument(
501
+ "--custom-prefix",
502
+ action="append",
503
+ default=None,
504
+ help="Filename substring marking a file as custom rather than vendor. Repeatable. Defaults to `td-`.",
505
+ )
506
+ parser.add_argument(
507
+ "--format",
508
+ choices=("markdown", "json"),
509
+ default="markdown",
510
+ help="Output format.",
511
+ )
512
+ parser.add_argument(
513
+ "--strict",
514
+ action="store_true",
515
+ help="Exit 1 when any vendor change is missing delimiters.",
516
+ )
517
+ args = parser.parse_args()
518
+
519
+ prefixes = tuple(args.custom_prefix or ["td-"])
520
+ base_ref = resolve_base_ref(args.base)
521
+ report = build_report(base_ref, prefixes, args.paths)
522
+
523
+ if args.format == "json":
524
+ print(json.dumps(report, indent=2))
525
+ else:
526
+ print_markdown(report)
527
+
528
+ if args.strict:
529
+ files: list[dict[str, object]] = report["files"] # type: ignore[assignment]
530
+ if any(entry.get("needs_delimiters") or entry.get("malformed") for entry in files):
531
+ return 1
532
+ return 0
533
+
534
+
535
+ if __name__ == "__main__":
536
+ try:
537
+ raise SystemExit(main())
538
+ except subprocess.CalledProcessError as error:
539
+ sys.stderr.write(error.stderr or str(error))
540
+ raise SystemExit(error.returncode)
@@ -1,76 +0,0 @@
1
- ---
2
- name: record-changes
3
- version: 1.0.0
4
- description: Update `docs/changes.md` by summarizing the current branch against the primary development branch. Use when a developer asks to record branch changes, document theme customizations, refresh the project change log, or write a branch summary into `docs/changes.md`. Prefer comparing against `main`, but fall back to `origin/main`, `master`, or `origin/master` when the repository does not have a local `main` branch.
5
- ---
6
-
7
- # Record Changes
8
-
9
- Update `docs/changes.md` from the current branch diff. Preserve the existing document structure and add a concise, human-readable entry for the branch.
10
-
11
- ## Workflow
12
-
13
- ### 1. Collect Branch Context
14
-
15
- Run the bundled script first:
16
-
17
- ```bash
18
- python3 .agents/skills/record-changes/scripts/branch_diff_context.py
19
- ```
20
-
21
- If the developer specifies another comparison branch, pass it explicitly:
22
-
23
- ```bash
24
- python3 .agents/skills/record-changes/scripts/branch_diff_context.py --base release/x.y
25
- ```
26
-
27
- Read the existing `docs/changes.md` before editing so the new entry matches the current ordering, tone, and section layout.
28
-
29
- ### 2. Inspect the Actual Code Changes
30
-
31
- Use the script output to identify changed files, then inspect the relevant diffs and file contents with `git diff` and targeted file reads.
32
-
33
- Prioritize:
34
-
35
- - User-facing behavior changes
36
- - CMS setting/schema changes
37
- - CSS or markup changes that affect rendering
38
- - Renamed files and vendor-theme hotspots
39
-
40
- Do not summarize the `docs/changes.md` edit itself as part of the branch work. If the branch contains unrelated skill or tooling files, either omit them from the changelog entry or separate them clearly when they are relevant to the project's maintenance history.
41
-
42
- ### 3. Write the Changelog Entry
43
-
44
- Add a new entry near the top of `docs/changes.md`, directly under the intro, unless the file already uses another ordering convention.
45
-
46
- Follow the existing pattern:
47
-
48
- - `## <short title>`
49
- - `**Date:** YYYY-MM-DD`
50
- - `### Purpose`
51
- - `### Files changed`
52
- - `### Upgrade impact`
53
- - `### Notes`
54
-
55
- Guidelines:
56
-
57
- - Make the title describe the feature or fix, not the branch name.
58
- - Write `Purpose` in plain language with outcome-focused bullets.
59
- - Use the `Files changed` table to explain why each file matters.
60
- - Call out non-`td-` theme or vendor files in a separate subsection when in a Shopify project.
61
- - Mention renamed files explicitly.
62
- - Keep `Upgrade impact` brief and concrete.
63
- - Use `Notes` for implementation details, edge cases, or assumptions.
64
-
65
- ### 4. Verify Before Finishing
66
-
67
- Before wrapping up:
68
-
69
- - Re-read the new markdown entry in context.
70
- - Confirm every listed file appears in the diff.
71
- - Confirm the documented behavior matches the code, not just the branch name.
72
- - Mention any uncertainty if the diff is too broad to summarize with high confidence.
73
-
74
- ## Resource
75
-
76
- - `scripts/branch_diff_context.py`: Resolve the best available base branch and print a branch summary with file statuses and line counts.
@@ -1,4 +0,0 @@
1
- interface:
2
- display_name: 'Record Changes'
3
- short_description: 'Summarize branch changes into docs/changes.md'
4
- default_prompt: 'Use $record-changes to compare this branch to main and update docs/changes.md.'