td-ai-tools 1.3.1 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "td-ai-tools",
3
- "version": "1.3.1",
3
+ "version": "1.3.2",
4
4
  "description": "Install agent skills and packs into your project",
5
5
  "type": "module",
6
6
  "scripts": {
package/skills/README.md CHANGED
@@ -14,9 +14,9 @@
14
14
  - `pr-solver`: Resolve GitHub pull request feedback by querying unresolved review conversations with the GitHub GraphQL API…
15
15
  - `pull-request`: Generates well-structured GitHub pull request descriptions for Shopify theme development teams by analyzing…
16
16
  - `pull-request-statamic`: Generates GitHub pull request descriptions for Statamic and Laravel development by analyzing git diffs and…
17
- - `record-changes`: Update `docs/changes.md` by summarizing the current branch against the primary development branch.
18
17
  - `shopify-cli`: Shopify CLI workflows for theme development.
19
18
  - `shopify-lint`: Run Shopify CLI Theme Check and Theory Digital's bundled Liquid, JavaScript, and CSS rules together, linting…
19
+ - `shopify-pre-pr`: Prepare a Shopify theme branch for pull request.
20
20
  - `stylesheet-migration`: Migrate Shopify Liquid `{% stylesheet %}` blocks into theme CSS assets using bundled Python scripts.
21
21
  - `td-js-vanilla-rules`: Theory Digital vanilla JavaScript standards for Shopify theme work.
22
22
  - `td-review`: Run parallel code review agents on a PR (including TD theme compliance) and produce a synthesized findings…
@@ -0,0 +1,129 @@
1
+ ---
2
+ name: shopify-pre-pr
3
+ version: 2.0.0
4
+ description: Prepare a Shopify theme branch for pull request. Audits every modified vendor file (any file whose name does not contain `td-`) for TD CHANGE delimiter comments and adds the missing ones, then updates `docs/changes.md` with a branch summary. Use when a developer asks to prep a branch for PR, wrap or check vendor edits with TD CHANGE comments, record branch changes, document theme customizations, or refresh the project change log. 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
+ # Shopify Pre-PR
8
+
9
+ Two passes over the current branch before it becomes a pull request:
10
+
11
+ 1. **Vendor delimiters** — every change to a vendor file must be wrapped in TD CHANGE comments. Add the missing ones.
12
+ 2. **Changelog** — record the branch in `docs/changes.md`.
13
+
14
+ Run them in that order, so the changelog describes the final state of the branch.
15
+
16
+ Vendor file: any changed file whose **basename does not contain `td-`** (`sections/header.liquid` is vendor; `sections/td-header.liquid` and `snippets/_td-globals.liquid` are custom). A `td-` directory does not make the files inside it custom.
17
+
18
+ ## Part 1 — Vendor Delimiter Audit
19
+
20
+ ### 1. Run the audit
21
+
22
+ ```bash
23
+ python3 .agents/skills/shopify-pre-pr/scripts/vendor_change_audit.py
24
+ ```
25
+
26
+ Options:
27
+
28
+ - `--base release/x.y` — compare against an explicit base ref.
29
+ - `--custom-prefix th-` — different custom prefix (repeatable).
30
+ - `--format json` — machine-readable output.
31
+ - `--strict` — exit 1 when any vendor change is missing delimiters, for hooks and CI.
32
+ - Trailing paths — limit the audit to specific files or directories.
33
+
34
+ The script compares the working tree against the merge base, so uncommitted work is included. For each modified vendor file it reports the correct comment syntax and the changed line ranges that are **not** inside a TD CHANGE block.
35
+
36
+ Coverage is computed by scanning the **entire file** for delimiters and tracking nesting depth, not by looking at the diff context. A change is already covered when any enclosing block wraps it at any depth — including delimiters that were committed long before this branch. Do not add a second pair inside a block that already contains the change.
37
+
38
+ ### 2. Add the missing delimiters
39
+
40
+ Read `references/td-change-delimiters.md` for the per-language syntax and placement rules, then edit each file the audit lists under **Needs delimiters**.
41
+
42
+ Core rules:
43
+
44
+ - Match the comment syntax to the language, and to the region inside `.liquid` files — `{% comment %}` tags in markup, `//` inside `{% javascript %}` or an inline `<script>`, `/* */` inside `{% stylesheet %}`.
45
+ - Wrap the smallest complete, syntactically valid unit containing the change. Never split an open tag, attribute list, statement, or declaration block, and keep Liquid control flow balanced inside the block.
46
+ - One delimiter pair per contiguous change, not one per line.
47
+ - Do not reformat, reorder, or otherwise touch the surrounding vendor code — the only edit in this pass is inserting comment lines.
48
+ - Never invent delimiters for changes the audit reports as not delimitable (`{% schema %}` blocks, `.json` files). Document those in Part 2 instead.
49
+
50
+ ### 3. Resolve the other findings
51
+
52
+ - **Malformed markers** — an unclosed `TD CHANGE` or an orphan `END TD CHANGE`. Repair the pair; do not delete a marker just to balance the file, since existing wrapped edits must stay marked.
53
+ - **Cannot be delimited** — `.json` files, unrecognized file types, and changes inside a `{% schema %}` block. These never appear under **Needs delimiters** because no comment syntax exists for them; carry each one into the changelog's vendor subsection with reapply detail.
54
+ - **Other vendor-path changes** — new files without a custom prefix (rename to `td-` unless there is a documented reason) and deleted vendor files (confirm intent).
55
+
56
+ ### 4. Verify
57
+
58
+ Re-run the audit. It must report zero files needing delimiters and no malformed markers before moving on. Then confirm the theme still parses — for example via the `shopify-lint` skill or `shopify theme check` — because a misplaced comment inside Liquid markup is a syntax error, not a no-op.
59
+
60
+ ## Part 2 — Record Changes
61
+
62
+ Update `docs/changes.md` from the current branch diff. Preserve the existing document structure and add a concise, human-readable entry for the branch.
63
+
64
+ ### 1. Collect branch context
65
+
66
+ ```bash
67
+ python3 .agents/skills/shopify-pre-pr/scripts/branch_diff_context.py
68
+ ```
69
+
70
+ If the developer specifies another comparison branch, pass it explicitly:
71
+
72
+ ```bash
73
+ python3 .agents/skills/shopify-pre-pr/scripts/branch_diff_context.py --base release/x.y
74
+ ```
75
+
76
+ Read the existing `docs/changes.md` before editing so the new entry matches the current ordering, tone, and section layout.
77
+
78
+ ### 2. Inspect the actual code changes
79
+
80
+ Use the script output to identify changed files, then inspect the relevant diffs and file contents with `git diff` and targeted file reads.
81
+
82
+ Prioritize:
83
+
84
+ - User-facing behavior changes
85
+ - CMS setting/schema changes
86
+ - CSS or markup changes that affect rendering
87
+ - Renamed files and vendor-theme hotspots
88
+
89
+ Do not summarize the `docs/changes.md` edit itself, or the delimiter comments added in Part 1, as 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.
90
+
91
+ ### 3. Write the changelog entry
92
+
93
+ Add a new entry near the top of `docs/changes.md`, directly under the intro, unless the file already uses another ordering convention.
94
+
95
+ Follow the existing pattern:
96
+
97
+ - `## <short title>`
98
+ - `**Date:** YYYY-MM-DD`
99
+ - `### Purpose`
100
+ - `### Files changed`
101
+ - `### Upgrade impact`
102
+ - `### Notes`
103
+
104
+ Guidelines:
105
+
106
+ - Make the title describe the feature or fix, not the branch name.
107
+ - Write `Purpose` in plain language with outcome-focused bullets.
108
+ - Use the `Files changed` table to explain why each file matters.
109
+ - Call out non-`td-` theme or vendor files in a separate subsection, and for each one record what changed and how to reapply it after a vendor theme update.
110
+ - List every vendor edit that could not be delimited (`{% schema %}` blocks, JSON templates) explicitly in that subsection — the comments cannot mark them, so the changelog is the only record.
111
+ - Mention renamed files explicitly.
112
+ - Keep `Upgrade impact` brief and concrete.
113
+ - Use `Notes` for implementation details, edge cases, or assumptions.
114
+
115
+ ### 4. Verify before finishing
116
+
117
+ Before wrapping up:
118
+
119
+ - Re-read the new markdown entry in context.
120
+ - Confirm every listed file appears in the diff.
121
+ - Confirm the documented behavior matches the code, not just the branch name.
122
+ - Confirm every vendor file in the diff is either delimited or documented.
123
+ - Mention any uncertainty if the diff is too broad to summarize with high confidence.
124
+
125
+ ## Resources
126
+
127
+ - `scripts/vendor_change_audit.py`: Report which vendor-file changes lack TD CHANGE delimiters, with the correct comment syntax per file and per Liquid region.
128
+ - `scripts/branch_diff_context.py`: Resolve the best available base branch and print a branch summary with file statuses and line counts.
129
+ - `references/td-change-delimiters.md`: Delimiter format, per-language syntax table, and placement rules.
@@ -0,0 +1,4 @@
1
+ interface:
2
+ display_name: 'Shopify Pre-PR'
3
+ short_description: 'Delimit vendor edits and update docs/changes.md before a PR'
4
+ default_prompt: 'Use $shopify-pre-pr to wrap this branch''s vendor file changes in TD CHANGE comments and update docs/changes.md.'
@@ -0,0 +1,88 @@
1
+ # TD CHANGE Delimiters
2
+
3
+ Every edit to a vendor file (any file whose basename does not contain `td-`) must sit between a pair of TD CHANGE comments so the edit can be found and reapplied after a vendor theme update.
4
+
5
+ ## Canonical format
6
+
7
+ ```
8
+ // ------------TD CHANGE-----------
9
+ // ----------END TD CHANGE---------
10
+ ```
11
+
12
+ Keep the marker text and dash counts exactly as above. Only the comment syntax changes per language.
13
+
14
+ ## Syntax by language
15
+
16
+ | File | Start | End |
17
+ |------|-------|-----|
18
+ | `.liquid` (markup) | `{% comment %} ------------TD CHANGE----------- {% endcomment %}` | `{% comment %} ----------END TD CHANGE--------- {% endcomment %}` |
19
+ | `.js`, `.mjs`, `.cjs`, `.ts` | `// ------------TD CHANGE-----------` | `// ----------END TD CHANGE---------` |
20
+ | `.css` | `/* ------------TD CHANGE----------- */` | `/* ----------END TD CHANGE--------- */` |
21
+ | `.scss`, `.sass` | `// ------------TD CHANGE-----------` | `// ----------END TD CHANGE---------` |
22
+ | `.html`, `.svg`, `.xml` | `<!-- ------------TD CHANGE----------- -->` | `<!-- ----------END TD CHANGE--------- -->` |
23
+ | `.yml`, `.yaml`, `.sh` | `# ------------TD CHANGE-----------` | `# ----------END TD CHANGE---------` |
24
+ | `.json` | Not possible — see below | |
25
+
26
+ Liquid comment tags are preferred over `<!-- -->` inside `.liquid` files because they are stripped server-side and never reach the rendered page.
27
+
28
+ ## Regions inside a `.liquid` file
29
+
30
+ A `.liquid` file mixes languages, so the delimiter syntax depends on where the edit lands:
31
+
32
+ - Inside `{% stylesheet %}` … `{% endstylesheet %}` → CSS block comments.
33
+ - Inside `{% javascript %}` … `{% endjavascript %}` or an inline `<script>` → `//` line comments.
34
+ - Inside `{% schema %}` … `{% endschema %}` → **cannot be commented** (it is JSON). Document the schema edit in `docs/changes.md` instead of wrapping it.
35
+
36
+ ## Placement rules
37
+
38
+ - Put the delimiters on their own lines, at the indentation of the code they wrap.
39
+ - Wrap the smallest complete, syntactically valid unit that contains the change — never split an open HTML tag, a Liquid tag, an attribute list, a CSS declaration block, or a statement.
40
+ - Keep Liquid control flow balanced inside the block. If a change adds an `{% if %}` whose `{% endif %}` is elsewhere, extend the block to include both.
41
+ - Wrap contiguous changes once rather than emitting a delimiter pair per line. Separate hunks in the same file get their own pairs.
42
+ - Never nest a new pair inside an existing pair that already contains the change — coverage counts at any depth, so an enclosing block is already sufficient.
43
+ - When a change deletes vendor code, leave the delimiters around the surrounding region and note the deletion in `docs/changes.md`; a comment cannot mark absent lines on its own.
44
+
45
+ ## Formats that cannot carry comments
46
+
47
+ JSON (`templates/*.json`, `locales/*.json`, `config/settings_data.json`) has no comment syntax. Do not attempt to fake one. Record these edits in the vendor subsection of the `docs/changes.md` entry with enough detail to reapply them.
48
+
49
+ ## Examples
50
+
51
+ Liquid markup:
52
+
53
+ ```liquid
54
+ <div class="header__inner">
55
+ {% comment %} ------------TD CHANGE----------- {% endcomment %}
56
+ {% render 'td-announcement' %}
57
+ {% comment %} ----------END TD CHANGE--------- {% endcomment %}
58
+ </div>
59
+ ```
60
+
61
+ CSS:
62
+
63
+ ```css
64
+ /* ------------TD CHANGE----------- */
65
+ .header { --header-height: 72px; }
66
+ /* ----------END TD CHANGE--------- */
67
+ ```
68
+
69
+ JavaScript:
70
+
71
+ ```js
72
+ // ------------TD CHANGE-----------
73
+ if (!customElements.get('td-cart-drawer')) {
74
+ customElements.define('td-cart-drawer', TdCartDrawer);
75
+ }
76
+ // ----------END TD CHANGE---------
77
+ ```
78
+
79
+ Stylesheet block inside a vendor section:
80
+
81
+ ```liquid
82
+ {% stylesheet %}
83
+ .card { display: grid; }
84
+ /* ------------TD CHANGE----------- */
85
+ .card { gap: var(--td-gap); }
86
+ /* ----------END TD CHANGE--------- */
87
+ {% endstylesheet %}
88
+ ```
@@ -0,0 +1,424 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ import os
5
+ import subprocess
6
+ import tempfile
7
+ import unittest
8
+
9
+ from vendor_change_audit import (
10
+ coverage_for_range,
11
+ delimiters,
12
+ build_report,
13
+ comment_style,
14
+ is_custom,
15
+ parse_hunk_ranges,
16
+ scan_liquid_regions,
17
+ scan_markers,
18
+ )
19
+
20
+
21
+ SCRIPT = Path(__file__).resolve().parent / "vendor_change_audit.py"
22
+
23
+
24
+ def covered(text: str) -> set[int]:
25
+ return scan_markers(text.splitlines())["covered"]
26
+
27
+
28
+ class MarkerScanTests(unittest.TestCase):
29
+ def test_lines_between_markers_are_covered(self) -> None:
30
+ result = scan_markers(
31
+ [
32
+ "const a = 1;",
33
+ "// ------------TD CHANGE-----------",
34
+ "const b = 2;",
35
+ "// ----------END TD CHANGE---------",
36
+ "const c = 3;",
37
+ ]
38
+ )
39
+
40
+ self.assertEqual(result["covered"], {2, 3, 4})
41
+ self.assertEqual(result["blocks"], [{"start": 2, "end": 4, "depth": 1}])
42
+ self.assertEqual(result["unclosed_starts"], [])
43
+ self.assertEqual(result["unmatched_ends"], [])
44
+
45
+ def test_nested_blocks_keep_outer_coverage(self) -> None:
46
+ result = scan_markers(
47
+ [
48
+ "outer before",
49
+ "{% comment %} ------------TD CHANGE----------- {% endcomment %}",
50
+ "still inside outer",
51
+ "{% comment %} ------------TD CHANGE----------- {% endcomment %}",
52
+ "inside inner",
53
+ "{% comment %} ----------END TD CHANGE--------- {% endcomment %}",
54
+ "back in outer only",
55
+ "{% comment %} ----------END TD CHANGE--------- {% endcomment %}",
56
+ "outer after",
57
+ ]
58
+ )
59
+
60
+ self.assertEqual(result["covered"], {2, 3, 4, 5, 6, 7, 8})
61
+ self.assertEqual(
62
+ result["blocks"],
63
+ [{"start": 2, "end": 8, "depth": 1}, {"start": 4, "end": 6, "depth": 2}],
64
+ )
65
+
66
+ def test_end_marker_is_not_mistaken_for_a_start(self) -> None:
67
+ result = scan_markers(
68
+ [
69
+ "// ----------END TD CHANGE---------",
70
+ "after",
71
+ ]
72
+ )
73
+
74
+ self.assertEqual(result["unmatched_ends"], [1])
75
+ self.assertEqual(result["covered"], {1})
76
+ self.assertNotIn(2, result["covered"])
77
+
78
+ def test_unclosed_start_is_reported(self) -> None:
79
+ result = scan_markers(["/* ------------TD CHANGE----------- */", "body {}"])
80
+
81
+ self.assertEqual(result["unclosed_starts"], [1])
82
+ self.assertEqual(result["blocks"], [])
83
+
84
+ def test_loose_marker_spellings_still_count(self) -> None:
85
+ self.assertEqual(
86
+ covered("a\n# TD-CHANGE\nb\n# end td change\nc\n"),
87
+ {2, 3, 4},
88
+ )
89
+
90
+
91
+ class CoverageTests(unittest.TestCase):
92
+ def test_range_fully_inside_block(self) -> None:
93
+ self.assertEqual(coverage_for_range({5, 6, 7}, 5, 7), "covered")
94
+
95
+ def test_range_outside_block(self) -> None:
96
+ self.assertEqual(coverage_for_range({5, 6, 7}, 10, 12), "uncovered")
97
+
98
+ def test_range_straddling_a_block_edge(self) -> None:
99
+ self.assertEqual(coverage_for_range({5, 6, 7}, 6, 9), "partial")
100
+
101
+
102
+ class HunkParsingTests(unittest.TestCase):
103
+ def test_parses_multi_line_hunks(self) -> None:
104
+ diff = "@@ -10,2 +10,3 @@\n+new\n@@ -40 +41 @@\n+edit\n"
105
+
106
+ self.assertEqual(parse_hunk_ranges(diff), [(10, 12), (41, 41)])
107
+
108
+ def test_pure_deletion_reports_the_adjacent_line(self) -> None:
109
+ self.assertEqual(parse_hunk_ranges("@@ -10,3 +9,0 @@\n-gone\n"), [(9, 9)])
110
+
111
+ def test_deletion_at_file_start_clamps_to_line_one(self) -> None:
112
+ self.assertEqual(parse_hunk_ranges("@@ -1,3 +0,0 @@\n-gone\n"), [(1, 1)])
113
+
114
+
115
+ class ClassificationTests(unittest.TestCase):
116
+ def test_custom_prefix_detected_anywhere_in_basename(self) -> None:
117
+ self.assertTrue(is_custom("sections/td-hero.liquid", ("td-",)))
118
+ self.assertTrue(is_custom("snippets/_td-helper.liquid", ("td-",)))
119
+ self.assertFalse(is_custom("sections/header.liquid", ("td-",)))
120
+
121
+ def test_directory_named_td_does_not_make_a_file_custom(self) -> None:
122
+ self.assertFalse(is_custom("td-theme/sections/header.liquid", ("td-",)))
123
+
124
+ def test_json_has_no_comment_style(self) -> None:
125
+ self.assertIsNone(comment_style("templates/index.json"))
126
+
127
+ def test_delimiters_match_the_house_format(self) -> None:
128
+ style = comment_style("assets/base.js")
129
+ assert style is not None
130
+ self.assertEqual(
131
+ delimiters(style),
132
+ ("// ------------TD CHANGE-----------", "// ----------END TD CHANGE---------"),
133
+ )
134
+
135
+ def test_liquid_uses_comment_tags(self) -> None:
136
+ style = comment_style("sections/header.liquid")
137
+ assert style is not None
138
+ self.assertEqual(
139
+ delimiters(style),
140
+ (
141
+ "{% comment %} ------------TD CHANGE----------- {% endcomment %}",
142
+ "{% comment %} ----------END TD CHANGE--------- {% endcomment %}",
143
+ ),
144
+ )
145
+
146
+
147
+ class LiquidRegionTests(unittest.TestCase):
148
+ def test_schema_block_is_located(self) -> None:
149
+ regions = scan_liquid_regions(
150
+ [
151
+ "<div>markup</div>",
152
+ "{% schema %}",
153
+ '{ "name": "Header" }',
154
+ "{% endschema %}",
155
+ ]
156
+ )
157
+
158
+ self.assertEqual(regions, [{"name": "schema", "start": 2, "end": 4, "extension": None}])
159
+
160
+ def test_stylesheet_and_javascript_blocks_map_to_their_syntax(self) -> None:
161
+ regions = scan_liquid_regions(
162
+ [
163
+ "{% stylesheet %}",
164
+ ".a { color: red; }",
165
+ "{% endstylesheet %}",
166
+ "{%- javascript -%}",
167
+ "const a = 1;",
168
+ "{%- endjavascript -%}",
169
+ ]
170
+ )
171
+
172
+ self.assertEqual(
173
+ regions,
174
+ [
175
+ {"name": "stylesheet", "start": 1, "end": 3, "extension": ".css"},
176
+ {"name": "javascript", "start": 4, "end": 6, "extension": ".js"},
177
+ ],
178
+ )
179
+
180
+ def test_external_script_tag_is_not_treated_as_a_js_region(self) -> None:
181
+ regions = scan_liquid_regions(['<script src="{{ \'a.js\' | asset_url }}" defer></script>'])
182
+
183
+ self.assertEqual(regions, [])
184
+
185
+
186
+ class RepoFixture:
187
+ """A throwaway git repo with a `main` branch and a feature branch."""
188
+
189
+ def __init__(self, root: Path) -> None:
190
+ self.root = root
191
+
192
+ def git(self, *args: str) -> str:
193
+ completed = subprocess.run(
194
+ ["git", *args],
195
+ cwd=self.root,
196
+ check=True,
197
+ capture_output=True,
198
+ text=True,
199
+ )
200
+ return completed.stdout
201
+
202
+ def write(self, relative: str, content: str) -> None:
203
+ path = self.root / relative
204
+ path.parent.mkdir(parents=True, exist_ok=True)
205
+ path.write_text(content)
206
+
207
+ def init(self) -> None:
208
+ self.git("init", "--initial-branch=main")
209
+ self.git("config", "user.email", "test@example.com")
210
+ self.git("config", "user.name", "Test")
211
+
212
+ def commit(self, message: str) -> None:
213
+ self.git("add", "-A")
214
+ self.git("commit", "-m", message)
215
+
216
+
217
+ class BuildReportTests(unittest.TestCase):
218
+ def setUp(self) -> None:
219
+ self._tmp = tempfile.TemporaryDirectory()
220
+ self._cwd = os.getcwd()
221
+ self.repo = RepoFixture(Path(self._tmp.name))
222
+ self.repo.init()
223
+ self.repo.write(
224
+ "sections/header.liquid",
225
+ "<header>\n <span>Vendor</span>\n</header>\n",
226
+ )
227
+ self.repo.write("assets/base.css", "body { margin: 0; }\n")
228
+ self.repo.write("sections/td-hero.liquid", "<section>Hero</section>\n")
229
+ self.repo.write("templates/index.json", '{ "sections": {} }\n')
230
+ self.repo.commit("base")
231
+ self.repo.git("checkout", "-b", "feature")
232
+ os.chdir(self.repo.root)
233
+
234
+ def tearDown(self) -> None:
235
+ os.chdir(self._cwd)
236
+ self._tmp.cleanup()
237
+
238
+ def entry_for(self, report: dict[str, object], path: str) -> dict[str, object]:
239
+ for entry in report["files"]: # type: ignore[union-attr]
240
+ if entry["path"] == path:
241
+ return entry
242
+ raise AssertionError(f"no entry for {path}")
243
+
244
+ def test_undelimited_vendor_edit_is_flagged(self) -> None:
245
+ self.repo.write(
246
+ "sections/header.liquid",
247
+ "<header>\n <span>Vendor</span>\n <span>Added</span>\n</header>\n",
248
+ )
249
+
250
+ report = build_report("main", ("td-",), [])
251
+ entry = self.entry_for(report, "sections/header.liquid")
252
+
253
+ self.assertEqual(entry["category"], "vendor_modified")
254
+ self.assertTrue(entry["needs_delimiters"])
255
+ self.assertEqual(entry["hunks"], [{"start": 3, "end": 3, "coverage": "uncovered"}])
256
+
257
+ def test_delimited_vendor_edit_passes(self) -> None:
258
+ self.repo.write(
259
+ "sections/header.liquid",
260
+ "<header>\n"
261
+ " <span>Vendor</span>\n"
262
+ " {% comment %} ------------TD CHANGE----------- {% endcomment %}\n"
263
+ " <span>Added</span>\n"
264
+ " {% comment %} ----------END TD CHANGE--------- {% endcomment %}\n"
265
+ "</header>\n",
266
+ )
267
+
268
+ report = build_report("main", ("td-",), [])
269
+ entry = self.entry_for(report, "sections/header.liquid")
270
+
271
+ self.assertFalse(entry["needs_delimiters"])
272
+ self.assertEqual([hunk["coverage"] for hunk in entry["hunks"]], ["covered"])
273
+
274
+ def test_edit_inside_a_pre_existing_outer_block_passes(self) -> None:
275
+ # The delimiters are already on main, far from the new edit: coverage has
276
+ # to be judged from the whole file, not from the diff context.
277
+ self.repo.write(
278
+ "assets/base.css",
279
+ "/* ------------TD CHANGE----------- */\n"
280
+ "body { margin: 0; }\n"
281
+ ".a {}\n"
282
+ ".b {}\n"
283
+ "/* ----------END TD CHANGE--------- */\n",
284
+ )
285
+ self.repo.commit("wrap existing region")
286
+ self.repo.write(
287
+ "assets/base.css",
288
+ "/* ------------TD CHANGE----------- */\n"
289
+ "body { margin: 0; }\n"
290
+ ".a {}\n"
291
+ ".new { color: red; }\n"
292
+ ".b {}\n"
293
+ "/* ----------END TD CHANGE--------- */\n",
294
+ )
295
+
296
+ report = build_report("main", ("td-",), [])
297
+ entry = self.entry_for(report, "assets/base.css")
298
+
299
+ self.assertFalse(entry["needs_delimiters"])
300
+
301
+ def test_custom_and_ignored_files_are_not_audited(self) -> None:
302
+ self.repo.write("sections/td-hero.liquid", "<section>Hero v2</section>\n")
303
+ self.repo.write("docs/changes.md", "# Changes\n")
304
+
305
+ report = build_report("main", ("td-",), [])
306
+
307
+ self.assertEqual(self.entry_for(report, "sections/td-hero.liquid")["category"], "custom")
308
+ self.assertEqual(self.entry_for(report, "docs/changes.md")["category"], "ignored")
309
+
310
+ def test_json_vendor_edit_is_reported_as_not_delimitable(self) -> None:
311
+ self.repo.write("templates/index.json", '{ "sections": { "a": {} } }\n')
312
+
313
+ report = build_report("main", ("td-",), [])
314
+ entry = self.entry_for(report, "templates/index.json")
315
+
316
+ self.assertEqual(entry["category"], "not_delimitable")
317
+
318
+ def test_schema_edit_is_marked_undelimitable(self) -> None:
319
+ self.repo.write(
320
+ "sections/header.liquid",
321
+ "<header>\n"
322
+ " <span>Vendor</span>\n"
323
+ "</header>\n"
324
+ "{% schema %}\n"
325
+ '{ "name": "Header", "settings": [] }\n'
326
+ "{% endschema %}\n",
327
+ )
328
+ self.repo.commit("add schema")
329
+ self.repo.write(
330
+ "sections/header.liquid",
331
+ "<header>\n"
332
+ " <span>Vendor</span>\n"
333
+ "</header>\n"
334
+ "{% schema %}\n"
335
+ '{ "name": "Header", "settings": [{ "id": "td_flag" }] }\n'
336
+ "{% endschema %}\n",
337
+ )
338
+
339
+ report = build_report("main", ("td-",), [])
340
+ entry = self.entry_for(report, "sections/header.liquid")
341
+
342
+ self.assertEqual(entry["hunks"][0]["region"], "schema")
343
+ self.assertFalse(entry["hunks"][0]["delimitable"])
344
+ # Impossible to delimit, so it must not be reported as actionable work.
345
+ self.assertFalse(entry["needs_delimiters"])
346
+ self.assertEqual(len(entry["undelimitable_hunks"]), 1)
347
+
348
+ completed = subprocess.run(
349
+ ["python3", str(SCRIPT), "--strict"],
350
+ cwd=self.repo.root,
351
+ capture_output=True,
352
+ text=True,
353
+ )
354
+
355
+ self.assertEqual(completed.returncode, 0)
356
+ self.assertIn("Cannot be delimited", completed.stdout)
357
+ self.assertNotIn("Needs delimiters", completed.stdout)
358
+
359
+ def test_stylesheet_block_edit_suggests_css_delimiters(self) -> None:
360
+ self.repo.write(
361
+ "sections/header.liquid",
362
+ "<header></header>\n{% stylesheet %}\n.a {}\n{% endstylesheet %}\n",
363
+ )
364
+ self.repo.commit("add stylesheet block")
365
+ self.repo.write(
366
+ "sections/header.liquid",
367
+ "<header></header>\n{% stylesheet %}\n.a {}\n.b {}\n{% endstylesheet %}\n",
368
+ )
369
+
370
+ report = build_report("main", ("td-",), [])
371
+ entry = self.entry_for(report, "sections/header.liquid")
372
+
373
+ self.assertEqual(entry["hunks"][0]["region"], "stylesheet")
374
+ self.assertEqual(entry["hunks"][0]["start_delimiter"], "/* ------------TD CHANGE----------- */")
375
+
376
+ def test_untracked_vendor_file_is_reported(self) -> None:
377
+ self.repo.write("snippets/promo.liquid", "<p>promo</p>\n")
378
+
379
+ report = build_report("main", ("td-",), [])
380
+
381
+ self.assertEqual(self.entry_for(report, "snippets/promo.liquid")["category"], "vendor_added")
382
+
383
+ def test_malformed_markers_are_reported(self) -> None:
384
+ self.repo.write(
385
+ "assets/base.css",
386
+ "/* ------------TD CHANGE----------- */\nbody { margin: 1px; }\n",
387
+ )
388
+
389
+ report = build_report("main", ("td-",), [])
390
+ entry = self.entry_for(report, "assets/base.css")
391
+
392
+ self.assertTrue(entry["malformed"])
393
+ self.assertEqual(entry["unclosed_starts"], [1])
394
+
395
+ def test_strict_mode_exits_non_zero_on_uncovered_change(self) -> None:
396
+ self.repo.write(
397
+ "sections/header.liquid",
398
+ "<header>\n <span>Vendor</span>\n <span>Added</span>\n</header>\n",
399
+ )
400
+
401
+ completed = subprocess.run(
402
+ ["python3", str(SCRIPT), "--strict"],
403
+ cwd=self.repo.root,
404
+ capture_output=True,
405
+ text=True,
406
+ )
407
+
408
+ self.assertEqual(completed.returncode, 1)
409
+ self.assertIn("Needs delimiters", completed.stdout)
410
+
411
+ def test_clean_branch_exits_zero_in_strict_mode(self) -> None:
412
+ completed = subprocess.run(
413
+ ["python3", str(SCRIPT), "--strict"],
414
+ cwd=self.repo.root,
415
+ capture_output=True,
416
+ text=True,
417
+ )
418
+
419
+ self.assertEqual(completed.returncode, 0)
420
+ self.assertIn("No vendor files changed", completed.stdout)
421
+
422
+
423
+ if __name__ == "__main__":
424
+ unittest.main()
@@ -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.'