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 +1 -1
- package/skills/README.md +1 -1
- 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
package/package.json
CHANGED
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,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
|
+
```
|
|
Binary file
|
|
Binary file
|
|
@@ -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.
|
|
File without changes
|