trae-coding-engine 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +91 -0
  2. package/bin/coding-engine.js +16 -0
  3. package/lib/cli.js +86 -0
  4. package/lib/index.js +6 -0
  5. package/lib/setup.js +290 -0
  6. package/package.json +32 -0
  7. package/templates/.agents/skills/coding-architecture/SKILL.md +347 -0
  8. package/templates/.agents/skills/coding-code-review/SKILL.md +95 -0
  9. package/templates/.agents/skills/coding-cve-remediation/SKILL.md +163 -0
  10. package/templates/.agents/skills/coding-db-schema/SKILL.md +273 -0
  11. package/templates/.agents/skills/coding-deploy-config/SKILL.md +129 -0
  12. package/templates/.agents/skills/coding-docs-audit/SKILL.md +285 -0
  13. package/templates/.agents/skills/coding-docs-audit-autofix/SKILL.md +33 -0
  14. package/templates/.agents/skills/coding-http-api/SKILL.md +269 -0
  15. package/templates/.agents/skills/coding-issue-autodev/SKILL.md +172 -0
  16. package/templates/.agents/skills/coding-merge-request/SKILL.md +199 -0
  17. package/templates/.agents/skills/coding-observability/SKILL.md +193 -0
  18. package/templates/.agents/skills/coding-refactor-insight/SKILL.md +89 -0
  19. package/templates/.agents/skills/coding-release-merge/SKILL.md +224 -0
  20. package/templates/.agents/skills/coding-runtime-adapter/SKILL.md +115 -0
  21. package/templates/.agents/skills/coding-testing/SKILL.md +169 -0
  22. package/templates/AGENTS.md +179 -0
  23. package/templates/MR-Template-Default.md +33 -0
  24. package/templates/Makefile +370 -0
  25. package/templates/REGISTRY.md +53 -0
  26. package/templates/scripts/check-adr.sh +283 -0
  27. package/templates/scripts/check-comment-i18n.py +209 -0
  28. package/templates/scripts/check-comment-i18n.sh +38 -0
  29. package/templates/scripts/check-doc-refs.sh +40 -0
  30. package/templates/scripts/check-docs.sh +103 -0
  31. package/templates/scripts/check-go-names.sh +59 -0
  32. package/templates/scripts/check-iam-actions.py +126 -0
  33. package/templates/scripts/check-migration-sql-immutability.sh +232 -0
  34. package/templates/scripts/check-mr-desc.sh +165 -0
  35. package/templates/scripts/check-mr-size.sh +128 -0
  36. package/templates/scripts/check-mr-title.sh +123 -0
  37. package/templates/scripts/check-openapi.py +221 -0
  38. package/templates/scripts/check-rdb-structured-queries.sh +98 -0
  39. package/templates/scripts/check-repository-transactions.sh +100 -0
  40. package/templates/scripts/check-runbooks.sh +229 -0
  41. package/templates/scripts/check-skill-router-sync.py +331 -0
  42. package/templates/scripts/check-skills.sh +243 -0
  43. package/templates/scripts/docs-audit-to-issues.py +373 -0
  44. package/templates/scripts/docs-verification-reminder.sh +63 -0
  45. package/templates/scripts/find-ci-failures.sh +133 -0
  46. package/templates/scripts/find-stale-mrs.sh +72 -0
  47. package/templates/scripts/gen-adr-index.sh +122 -0
  48. package/templates/scripts/open-mr.sh +536 -0
  49. package/templates/scripts/regen-agent-md.sh +149 -0
  50. package/templates/scripts/run-mr-hygiene.sh +140 -0
  51. package/templates/scripts/runbook.sh +91 -0
  52. package/templates/scripts/self-maintenance-digest.py +125 -0
  53. package/templates/scripts/send-self-maintenance-lark-card.py +116 -0
  54. package/templates/scripts/skill-graph.sh +114 -0
  55. package/templates/scripts/skill-impact.sh +134 -0
  56. package/templates/scripts/skill-propose.sh +302 -0
  57. package/templates/scripts/skill-router-hook.sh +152 -0
@@ -0,0 +1,229 @@
1
+ #!/usr/bin/env bash
2
+ # Lint docs/runbooks/*.md for broken references so an oncall never follows a
3
+ # dead link or a stale source citation mid-incident. Three checks:
4
+ # 1. Relative links `](./X.md)` -> target must exist under docs/runbooks/.
5
+ # 2. In-page anchors `](#slug)` -> a heading in the SAME file must slugify
6
+ # to that slug (lowercase, spaces->hyphens, strip punctuation, KEEP unicode
7
+ # so `## 速查表` -> `速查表` and `## Cheat-sheet` -> `cheat-sheet` both resolve).
8
+ # 3. Backticked PATH-LIKE code refs (`dir/file.ext` or `dir/file.ext:NN`,
9
+ # ext in go|tpl|yaml|yml|thrift|sql|md|example|sh — an allow-list of text
10
+ # source/config types an oncall can grep offline; binaries and unlisted
11
+ # types are intentionally not treated as repo-path claims): must exist at
12
+ # the repo root; if `:NN`, the file must have at least NN lines. A ref whose
13
+ # first path segment is NOT a tracked repo-root entry (e.g. chart-relative
14
+ # `templates/secret.yaml`, absolute `/etc/conf/config.yaml`) is treated as a
15
+ # non-repo citation and skipped — it is not a repo-root path claim.
16
+ # Bare filenames without `/` (e.g. `controller.go:173`) are also skipped.
17
+ # 4. Every actual runbook (NOT README*.md / TEMPLATE.md / INCIDENTS.md) must
18
+ # carry a visible `**Last verified:** YYYY-MM-DD` line with a parseable ISO
19
+ # date — so an oncall can see staleness at a glance and lint flags a page
20
+ # that drifted without its metadata being re-touched. Missing or malformed
21
+ # date -> VIOLATION.
22
+ set -euo pipefail
23
+
24
+ # Force a UTF-8 locale: runbooks contain multibyte text (em-dash, CJK headings
25
+ # like 速查表). Under a C / non-UTF-8 locale, BSD grep/sed/tr abort with
26
+ # "illegal byte sequence" (SIGABRT) — which happens when invoked via `make`
27
+ # with a scrubbed environment. Pick the first UTF-8 locale that exists.
28
+ for _loc in C.UTF-8 en_US.UTF-8; do
29
+ if locale -a 2>/dev/null | grep -qx "$_loc"; then
30
+ export LC_ALL="$_loc" LANG="$_loc"
31
+ break
32
+ fi
33
+ done
34
+
35
+ cd "$(dirname "$0")/.."
36
+
37
+ RUNBOOK_DIR="docs/runbooks"
38
+ VIOLATIONS=0
39
+ GRANDFATHERED_HITS=0
40
+
41
+ # Grandfathered refs — a path-like citation we knowingly keep despite not
42
+ # resolving. Format: "<relpath>:<lineno>\t<reason>". Empty by design: prefer
43
+ # FIXING a ref over grandfathering (a wrong citation misleads oncall).
44
+ GRANDFATHERED=()
45
+
46
+ is_grandfathered() {
47
+ local key=$1
48
+ for g in "${GRANDFATHERED[@]+"${GRANDFATHERED[@]}"}"; do
49
+ [ "${g%%$'\t'*}" = "$key" ] && return 0
50
+ done
51
+ return 1
52
+ }
53
+
54
+ # Repo-root segments that mark a backticked ref as a genuine repo path claim.
55
+ # Built once from the git index so it tracks real top-level entries.
56
+ ROOT_ENTRIES="$(git ls-tree --name-only HEAD 2>/dev/null || ls -A)"
57
+
58
+ is_repo_root_segment() {
59
+ local seg=$1
60
+ printf '%s\n' "$ROOT_ENTRIES" | grep -qx -- "$seg"
61
+ }
62
+
63
+ # slugify <heading-text> -> GitHub slug, unicode preserved.
64
+ # GitHub's rule: lowercase -> strip punctuation (em-dash, etc.) -> replace each
65
+ # space with a hyphen. It does NOT collapse the consecutive hyphens that result
66
+ # when punctuation sat between two spaces, so `Step 2 — Migrate` -> `step-2--migrate`
67
+ # (double hyphen). Existing hyphens in the heading are preserved. Do not collapse.
68
+ slugify() {
69
+ # Strip ASCII punctuation plus the unicode dashes that show up in headings
70
+ # (em-dash —, en-dash –). CJK word characters (速查表) are NOT punctuation and
71
+ # are kept. Keeping the unicode-dash strip explicit avoids a blanket
72
+ # "remove all non-ASCII" that would also eat CJK.
73
+ # One sed pass: strip ASCII punctuation + unicode dashes, then spaces->hyphens.
74
+ printf '%s' "$1" \
75
+ | tr '[:upper:]' '[:lower:]' \
76
+ | sed -E 's/[][`~!@#$%^&*()+=|\\{}:;"'"'"'<>,.?/—–]//g; s/[[:space:]]/-/g'
77
+ }
78
+
79
+ # Collect all heading slugs in a file (one per line).
80
+ file_slugs() {
81
+ local f=$1 line heading
82
+ while IFS= read -r line; do
83
+ case "$line" in
84
+ \#*)
85
+ heading=$(printf '%s' "$line" | sed -E 's/^#+[[:space:]]*//')
86
+ # slugify emits no trailing newline; add one so each slug is its own line.
87
+ printf '%s\n' "$(slugify "$heading")"
88
+ ;;
89
+ esac
90
+ done <"$f"
91
+ }
92
+
93
+ report_violation() {
94
+ echo " [VIOLATION] $1"
95
+ VIOLATIONS=$((VIOLATIONS + 1))
96
+ }
97
+
98
+ # Files that are NOT incident runbooks and therefore exempt from the
99
+ # Last-verified requirement: the two READMEs, the copy-me TEMPLATE, and the
100
+ # append-only INCIDENTS log.
101
+ is_metadata_exempt() {
102
+ # README.*.md covers any translated front-door (README.zh.md, README.ja.md, …)
103
+ # without needing this list touched when a locale is added.
104
+ case "$(basename "$1")" in
105
+ README.md | README.*.md | TEMPLATE.md | INCIDENTS.md) return 0 ;;
106
+ *) return 1 ;;
107
+ esac
108
+ }
109
+
110
+ # Validate a YYYY-MM-DD string is a real calendar date (not just shaped right).
111
+ # Uses `date` for parsing; tries GNU (-d) then BSD (-j -f) so it works on Linux
112
+ # CI and macOS dev boxes alike.
113
+ is_iso_date() {
114
+ local d=$1
115
+ case "$d" in
116
+ [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]) ;;
117
+ *) return 1 ;;
118
+ esac
119
+ date -d "$d" +%Y-%m-%d >/dev/null 2>&1 && return 0
120
+ date -j -f %Y-%m-%d "$d" +%Y-%m-%d >/dev/null 2>&1 && return 0
121
+ return 1
122
+ }
123
+
124
+ # Check 4: a runbook must carry a `**Last verified:** YYYY-MM-DD` line.
125
+ check_last_verified() {
126
+ local f=$1 line date_str
127
+ is_metadata_exempt "$f" && return 0
128
+ # Grab the value after the FIRST `**Last verified:**` marker, then the leading
129
+ # date token (the line also carries Owner / Blast radius / Related fields).
130
+ line=$(grep -m1 -oE '\*\*Last verified:\*\*[[:space:]]*[0-9]{4}-[0-9]{2}-[0-9]{2}' "$f" || true)
131
+ if [ -z "$line" ]; then
132
+ report_violation "$f — missing a '**Last verified:** YYYY-MM-DD' metadata line"
133
+ return 0
134
+ fi
135
+ date_str=${line##* }
136
+ if ! is_iso_date "$date_str"; then
137
+ report_violation "$f — '**Last verified:** $date_str' is not a valid ISO date"
138
+ fi
139
+ }
140
+
141
+ check_file() {
142
+ local f=$1
143
+ local base slugs lineno
144
+ base=$(basename "$f")
145
+ # Slug set as newline list; held in a string + matched with an exact lookup.
146
+ slugs=$(file_slugs "$f")
147
+
148
+ # Extract each reference class in ONE grep pass over the whole file (with line
149
+ # numbers via -n). Iterating the small match set in pure bash avoids a
150
+ # per-line subshell storm that was slow enough to get OOM/SIGKILL'd under make.
151
+
152
+ # 1. Relative .md links: ](./X.md) or ](./X.md#anchor)
153
+ local m lineno hit path
154
+ while IFS= read -r m; do
155
+ [ -n "$m" ] || continue
156
+ lineno=${m%%:*}
157
+ hit=${m#*:}
158
+ path=${hit#*](./}; path=${path%)}; path=${path%%#*}
159
+ if [ ! -f "$RUNBOOK_DIR/$path" ]; then
160
+ report_violation "$f:$lineno — relative link './$path' has no target under $RUNBOOK_DIR/"
161
+ fi
162
+ done < <(grep -noE '\]\(\./[^)]+\.md(#[^)]*)?\)' "$f")
163
+
164
+ # 2. In-page anchors: ](#slug)
165
+ local slug
166
+ while IFS= read -r m; do
167
+ [ -n "$m" ] || continue
168
+ lineno=${m%%:*}
169
+ hit=${m#*:}
170
+ slug=${hit#*](#}; slug=${slug%)}
171
+ if ! printf '%s\n' "$slugs" | grep -qxF -- "$slug"; then
172
+ report_violation "$f:$lineno — anchor '#$slug' matches no heading in $base"
173
+ fi
174
+ done < <(grep -noE '\]\(#[^)]+\)' "$f")
175
+
176
+ # 3. Backticked path-like code refs.
177
+ local ref relpath want_lines first_seg key have
178
+ while IFS= read -r m; do
179
+ [ -n "$m" ] || continue
180
+ lineno=${m%%:*}
181
+ hit=${m#*:}
182
+ ref=${hit#\`}; ref=${ref%\`}
183
+ relpath=${ref%%:*}; want_lines=""
184
+ [ "$ref" != "$relpath" ] && want_lines=${ref#*:}
185
+ # Must contain a slash to be a path claim; skip bare filenames.
186
+ case "$relpath" in */*) ;; *) continue ;; esac
187
+ # First segment must be a tracked repo-root entry, else it's a
188
+ # chart-relative / absolute / non-repo citation — skip.
189
+ first_seg=${relpath%%/*}
190
+ [ -n "$first_seg" ] || continue # leading-slash absolute path
191
+ is_repo_root_segment "$first_seg" || continue
192
+
193
+ key="$f:$lineno"
194
+ if [ ! -f "$relpath" ]; then
195
+ if is_grandfathered "$key"; then
196
+ echo " [GRANDFATHERED] $key — missing path ref \`$ref\`"
197
+ GRANDFATHERED_HITS=$((GRANDFATHERED_HITS + 1))
198
+ else
199
+ report_violation "$f:$lineno — code ref \`$ref\` does not exist at repo root"
200
+ fi
201
+ continue
202
+ fi
203
+ if [ -n "$want_lines" ]; then
204
+ have=$(wc -l <"$relpath")
205
+ if [ "$have" -lt "$want_lines" ]; then
206
+ report_violation "$f:$lineno — code ref \`$ref\` points past EOF ($relpath has $have lines)"
207
+ fi
208
+ fi
209
+ done < <(grep -noE '`[A-Za-z0-9_./-]+/[A-Za-z0-9_.-]+\.(go|tpl|yaml|yml|thrift|sql|md|example|sh)(:[0-9]+)?`' "$f")
210
+ }
211
+
212
+ shopt -s nullglob
213
+ for f in "$RUNBOOK_DIR"/*.md; do
214
+ check_file "$f"
215
+ check_last_verified "$f"
216
+ done
217
+
218
+ if [ "$VIOLATIONS" -eq 0 ]; then
219
+ if [ "$GRANDFATHERED_HITS" -gt 0 ]; then
220
+ echo " [OK] no new violations (${GRANDFATHERED_HITS} grandfathered)"
221
+ else
222
+ echo " [OK] runbook links and citations all resolve"
223
+ fi
224
+ exit 0
225
+ fi
226
+
227
+ echo
228
+ echo " ${VIOLATIONS} broken runbook reference(s)."
229
+ exit 1
@@ -0,0 +1,331 @@
1
+ #!/usr/bin/env python3
2
+ """Verify the §Skill Router mapping is consistent across its three mirrors.
3
+
4
+ AGENTS.md §Skill Router is the cross-tool source of truth. The same path->skill
5
+ mapping is duplicated in two derived mirrors so each CLI can read it natively:
6
+
7
+ * .cursor/rules/skill-router.mdc (Cursor: `globs:` frontmatter + a table)
8
+ * scripts/skill-router-hook.sh (Claude Code PostToolUse hook: bash `case`)
9
+
10
+ Nothing regenerates these from the source, so an edit to one can silently drift
11
+ from the others (different glob dialects hide the divergence). This gate parses
12
+ all three, normalizes the glob dialects to a canonical form, and asserts:
13
+
14
+ 1. AGENTS.md table == hook `case` mapping (skill-set -> path-set)
15
+ 2. AGENTS.md table == .mdc table mapping
16
+ 3. .mdc `globs:` frontmatter == union of all .mdc table paths
17
+
18
+ It is a structural consistency check, not a generator: authors still edit each
19
+ file, but drift fails the build. Exit 0 when in sync, 1 on drift, 2 on a parse
20
+ or usage error.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import argparse
25
+ import re
26
+ import sys
27
+ from pathlib import Path
28
+
29
+ SKILL_RE = re.compile(r"coding-[a-z0-9-]+")
30
+ BACKTICK_RE = re.compile(r"`([^`]+)`")
31
+ # A bare glob token: path chars plus glob metacharacters, nothing shell-ish.
32
+ TOKEN_RE = re.compile(r"^[A-Za-z0-9_./*-]+$")
33
+
34
+
35
+ def expand_braces(token: str) -> list[str]:
36
+ """Expand a single `{a,b,c}` brace group into multiple tokens.
37
+
38
+ The router patterns use at most one brace group per token, but this recurses
39
+ so nested or multiple groups still expand correctly.
40
+ """
41
+ start = token.find("{")
42
+ if start == -1:
43
+ return [token]
44
+ depth = 0
45
+ for i in range(start, len(token)):
46
+ if token[i] == "{":
47
+ depth += 1
48
+ elif token[i] == "}":
49
+ depth -= 1
50
+ if depth == 0:
51
+ end = i
52
+ break
53
+ else:
54
+ # Unbalanced brace; treat literally rather than guessing.
55
+ return [token]
56
+ prefix, body, suffix = token[:start], token[start + 1 : end], token[end + 1 :]
57
+ out: list[str] = []
58
+ for alt in body.split(","):
59
+ for tail in expand_braces(suffix):
60
+ out.append(prefix + alt + tail)
61
+ return out
62
+
63
+
64
+ def normalize_path(raw: str) -> set[str]:
65
+ """Canonicalize one glob token so equivalent dialects compare equal.
66
+
67
+ Folds the AGENTS.md (`{a,b}/**`), .mdc (`/**`) and hook (`/*`) dialects onto
68
+ a shared form: brace-expand, collapse `**`->`*`, drop a leading `*/` and a
69
+ trailing `/*` directory glob. Returns a set because braces expand to many.
70
+
71
+ The `**`->`*` fold is intentional, not a bug: for the rows in use the three
72
+ dialects denote the same subtree. A bash `case` `*` matches across `/`, so
73
+ the hook's `dir/*` is the subtree `dir/**` written in AGENTS.md and the
74
+ .mdc. Folding them is what lets the gate compare the dialects at all. The
75
+ accepted blind spot is a single-level gitignore-style `*` in the .mdc
76
+ `globs:` list — there `*` does NOT cross `/`, so swapping `/**` for `/*`
77
+ there is a real semantic change this gate would treat as equivalent. That
78
+ has not happened and is cheap to catch in review; depth-sensitivity here
79
+ would instead flag the current intentionally-equivalent rows as drift.
80
+ """
81
+ token = raw.strip().strip("`").strip().strip('"')
82
+ out: set[str] = set()
83
+ for expanded in expand_braces(token):
84
+ p = expanded.replace("**", "*")
85
+ if p.startswith("*/"):
86
+ p = p[2:]
87
+ if p.endswith("/*"):
88
+ p = p[:-2]
89
+ if p:
90
+ out.add(p)
91
+ return out
92
+
93
+
94
+ def normalize_paths(tokens: list[str]) -> frozenset[str]:
95
+ out: set[str] = set()
96
+ for t in tokens:
97
+ out |= normalize_path(t)
98
+ return frozenset(out)
99
+
100
+
101
+ def skill_key(cell: str) -> frozenset[str]:
102
+ return frozenset(SKILL_RE.findall(cell))
103
+
104
+
105
+ def fail(msg: str) -> None:
106
+ print(f"[FAIL] {msg}", file=sys.stderr)
107
+
108
+
109
+ def is_separator_row(cells: list[str]) -> bool:
110
+ return all(set(c.strip()) <= {"-", ":"} and c.strip() for c in cells)
111
+
112
+
113
+ def parse_markdown_table(lines: list[str]) -> dict[frozenset[str], frozenset[str]]:
114
+ """Parse `| paths | skill | ... |` rows into {skill-set -> path-set}.
115
+
116
+ The first cell holds backtick-quoted, comma-separated path globs; the second
117
+ holds one or more backtick-quoted skill names. The header row (it names the
118
+ skill column "Load skill") and the `|---|` separator are skipped.
119
+ """
120
+ mapping: dict[frozenset[str], set[str]] = {}
121
+ for line in lines:
122
+ if not line.lstrip().startswith("|"):
123
+ continue
124
+ cells = [c.strip() for c in line.strip().strip("|").split("|")]
125
+ if len(cells) < 2 or is_separator_row(cells):
126
+ continue
127
+ if "load skill" in cells[1].lower():
128
+ continue # header row
129
+ paths = BACKTICK_RE.findall(cells[0])
130
+ skills = skill_key(cells[1])
131
+ if not paths or not skills:
132
+ continue
133
+ mapping.setdefault(skills, set()).update(normalize_paths(paths))
134
+ return {k: frozenset(v) for k, v in mapping.items()}
135
+
136
+
137
+ def slice_section(text: str, heading: str) -> list[str]:
138
+ """Return the lines of a `## heading` section, up to the next `## ` heading."""
139
+ lines = text.splitlines()
140
+ out: list[str] = []
141
+ in_section = False
142
+ for line in lines:
143
+ if line.startswith("## "):
144
+ if in_section:
145
+ break
146
+ in_section = line.strip() == f"## {heading}"
147
+ continue
148
+ if in_section:
149
+ out.append(line)
150
+ return out
151
+
152
+
153
+ def parse_agents_md(path: Path) -> dict[frozenset[str], frozenset[str]]:
154
+ section = slice_section(path.read_text(encoding="utf-8"), "Skill Router")
155
+ if not section:
156
+ raise ValueError(f"{path}: '## Skill Router' section not found")
157
+ return parse_markdown_table(section)
158
+
159
+
160
+ def parse_mdc_table(path: Path) -> dict[frozenset[str], frozenset[str]]:
161
+ text = path.read_text(encoding="utf-8")
162
+ # Skip the YAML frontmatter so its `globs:` list is not mistaken for a table.
163
+ parts = text.split("---")
164
+ body = "---".join(parts[2:]) if text.startswith("---") and len(parts) >= 3 else text
165
+ return parse_markdown_table(body.splitlines())
166
+
167
+
168
+ def parse_mdc_globs(path: Path) -> frozenset[str]:
169
+ text = path.read_text(encoding="utf-8")
170
+ if not text.startswith("---"):
171
+ raise ValueError(f"{path}: missing YAML frontmatter")
172
+ frontmatter = text.split("---")[1]
173
+ out: set[str] = set()
174
+ in_globs = False
175
+ for line in frontmatter.splitlines():
176
+ if re.match(r"^\s*globs:\s*$", line):
177
+ in_globs = True
178
+ continue
179
+ if in_globs:
180
+ m = re.match(r"^\s*-\s*[\"']?([^\"'\s]+)[\"']?\s*$", line)
181
+ if m:
182
+ out |= normalize_path(m.group(1))
183
+ elif line.strip() and not line.startswith(" "):
184
+ break # next top-level key
185
+ elif re.match(r"^\s*\w+:", line):
186
+ break # next mapping key at any indent
187
+ if not out:
188
+ raise ValueError(f"{path}: no `globs:` entries found")
189
+ return frozenset(out)
190
+
191
+
192
+ def parse_hook(path: Path) -> dict[frozenset[str], frozenset[str]]:
193
+ """Parse the first `case "$REL" in ... esac` block of the router hook.
194
+
195
+ Each branch lists glob patterns separated by `|` and terminated by `)`, then
196
+ a `SKILL="..."` assignment. A later ADR-note `case` block has no SKILL and is
197
+ ignored because we stop at the first `esac`.
198
+ """
199
+ text = path.read_text(encoding="utf-8")
200
+ start = text.find('case "$REL" in')
201
+ if start == -1:
202
+ raise ValueError(f"{path}: `case \"$REL\" in` block not found")
203
+ end = text.find("esac", start)
204
+ if end == -1:
205
+ raise ValueError(f"{path}: unterminated case block")
206
+ block = text[start:end]
207
+
208
+ mapping: dict[frozenset[str], set[str]] = {}
209
+ for branch in block.split(";;"):
210
+ sk = re.search(r'SKILL="([^"]*)"', branch)
211
+ if not sk:
212
+ continue
213
+ skills = skill_key(sk.group(1))
214
+ if not skills:
215
+ continue
216
+ pattern_text = branch[: sk.start()]
217
+ tokens: list[str] = []
218
+ for line in pattern_text.splitlines():
219
+ stripped = line.strip()
220
+ if not stripped or stripped.startswith("#"):
221
+ continue
222
+ for tok in re.split(r"[|)]", stripped):
223
+ tok = tok.strip().rstrip("\\").strip()
224
+ if tok and TOKEN_RE.match(tok):
225
+ tokens.append(tok)
226
+ if tokens:
227
+ mapping.setdefault(skills, set()).update(normalize_paths(tokens))
228
+ return {k: frozenset(v) for k, v in mapping.items()}
229
+
230
+
231
+ def diff_mapping(
232
+ name_a: str,
233
+ map_a: dict[frozenset[str], frozenset[str]],
234
+ name_b: str,
235
+ map_b: dict[frozenset[str], frozenset[str]],
236
+ ) -> list[str]:
237
+ errors: list[str] = []
238
+ keys = sorted(set(map_a) | set(map_b), key=lambda k: ",".join(sorted(k)))
239
+ for key in keys:
240
+ label = " + ".join(sorted(key))
241
+ a = map_a.get(key)
242
+ b = map_b.get(key)
243
+ if a is None:
244
+ errors.append(f"skill `{label}` present in {name_b} but missing in {name_a}")
245
+ continue
246
+ if b is None:
247
+ errors.append(f"skill `{label}` present in {name_a} but missing in {name_b}")
248
+ continue
249
+ only_a = a - b
250
+ only_b = b - a
251
+ if only_a:
252
+ errors.append(f"skill `{label}`: paths only in {name_a}: {sorted(only_a)}")
253
+ if only_b:
254
+ errors.append(f"skill `{label}`: paths only in {name_b}: {sorted(only_b)}")
255
+ return errors
256
+
257
+
258
+ def main() -> int:
259
+ parser = argparse.ArgumentParser(description=__doc__)
260
+ parser.add_argument(
261
+ "--root",
262
+ default=str(Path(__file__).resolve().parent.parent),
263
+ help="repo root containing AGENTS.md, .cursor/, scripts/ (default: repo root)",
264
+ )
265
+ args = parser.parse_args()
266
+ root = Path(args.root)
267
+
268
+ agents_md = root / "AGENTS.md"
269
+ mdc = root / ".cursor" / "rules" / "skill-router.mdc"
270
+ hook = root / "scripts" / "skill-router-hook.sh"
271
+ for f in (agents_md, mdc, hook):
272
+ if not f.is_file():
273
+ fail(f"required file not found: {f}")
274
+ return 2
275
+
276
+ try:
277
+ agents_map = parse_agents_md(agents_md)
278
+ mdc_map = parse_mdc_table(mdc)
279
+ mdc_globs = parse_mdc_globs(mdc)
280
+ hook_map = parse_hook(hook)
281
+ except ValueError as exc:
282
+ fail(str(exc))
283
+ return 2
284
+
285
+ # An empty parse means a parser/format regression, not "in sync". Without
286
+ # this guard, two sides both parsing to {} would diff-clean and pass — the
287
+ # gate would silently stop protecting anything. parse_mdc_globs already
288
+ # raises on an empty `globs:` list.
289
+ if not agents_map:
290
+ fail("AGENTS.md §Skill Router table parsed to an empty mapping")
291
+ return 2
292
+ if not hook_map:
293
+ fail("scripts/skill-router-hook.sh case block parsed to an empty mapping")
294
+ return 2
295
+ if not mdc_map:
296
+ fail(".cursor/rules/skill-router.mdc table parsed to an empty mapping")
297
+ return 2
298
+
299
+ errors: list[str] = []
300
+ errors += diff_mapping("AGENTS.md", agents_map, "skill-router-hook.sh", hook_map)
301
+ errors += diff_mapping("AGENTS.md", agents_map, "skill-router.mdc table", mdc_map)
302
+
303
+ # The .mdc `globs:` frontmatter has no skill labels; assert it covers exactly
304
+ # the same path set as its own table so the two halves cannot drift apart.
305
+ mdc_table_paths: set[str] = set()
306
+ for paths in mdc_map.values():
307
+ mdc_table_paths |= set(paths)
308
+ only_globs = mdc_globs - mdc_table_paths
309
+ only_table = mdc_table_paths - mdc_globs
310
+ if only_globs:
311
+ errors.append(f".mdc `globs:` lists paths absent from its table: {sorted(only_globs)}")
312
+ if only_table:
313
+ errors.append(f".mdc table lists paths absent from `globs:`: {sorted(only_table)}")
314
+
315
+ if errors:
316
+ fail("Skill Router mirrors are out of sync:")
317
+ for e in errors:
318
+ print(f" - {e}", file=sys.stderr)
319
+ print(
320
+ "\nUpdate AGENTS.md §Skill Router first, then mirror the change in\n"
321
+ ".cursor/rules/skill-router.mdc and scripts/skill-router-hook.sh.",
322
+ file=sys.stderr,
323
+ )
324
+ return 1
325
+
326
+ print("[OK] Skill Router is consistent across AGENTS.md, .cursor mirror, and hook")
327
+ return 0
328
+
329
+
330
+ if __name__ == "__main__":
331
+ sys.exit(main())