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
|
@@ -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.
|
|
File without changes
|