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.
- package/README.md +91 -0
- package/bin/coding-engine.js +16 -0
- package/lib/cli.js +86 -0
- package/lib/index.js +6 -0
- package/lib/setup.js +290 -0
- package/package.json +32 -0
- package/templates/.agents/skills/coding-architecture/SKILL.md +347 -0
- package/templates/.agents/skills/coding-code-review/SKILL.md +95 -0
- package/templates/.agents/skills/coding-cve-remediation/SKILL.md +163 -0
- package/templates/.agents/skills/coding-db-schema/SKILL.md +273 -0
- package/templates/.agents/skills/coding-deploy-config/SKILL.md +129 -0
- package/templates/.agents/skills/coding-docs-audit/SKILL.md +285 -0
- package/templates/.agents/skills/coding-docs-audit-autofix/SKILL.md +33 -0
- package/templates/.agents/skills/coding-http-api/SKILL.md +269 -0
- package/templates/.agents/skills/coding-issue-autodev/SKILL.md +172 -0
- package/templates/.agents/skills/coding-merge-request/SKILL.md +199 -0
- package/templates/.agents/skills/coding-observability/SKILL.md +193 -0
- package/templates/.agents/skills/coding-refactor-insight/SKILL.md +89 -0
- package/templates/.agents/skills/coding-release-merge/SKILL.md +224 -0
- package/templates/.agents/skills/coding-runtime-adapter/SKILL.md +115 -0
- package/templates/.agents/skills/coding-testing/SKILL.md +169 -0
- package/templates/AGENTS.md +179 -0
- package/templates/MR-Template-Default.md +33 -0
- package/templates/Makefile +370 -0
- package/templates/REGISTRY.md +53 -0
- package/templates/scripts/check-adr.sh +283 -0
- package/templates/scripts/check-comment-i18n.py +209 -0
- package/templates/scripts/check-comment-i18n.sh +38 -0
- package/templates/scripts/check-doc-refs.sh +40 -0
- package/templates/scripts/check-docs.sh +103 -0
- package/templates/scripts/check-go-names.sh +59 -0
- package/templates/scripts/check-iam-actions.py +126 -0
- package/templates/scripts/check-migration-sql-immutability.sh +232 -0
- package/templates/scripts/check-mr-desc.sh +165 -0
- package/templates/scripts/check-mr-size.sh +128 -0
- package/templates/scripts/check-mr-title.sh +123 -0
- package/templates/scripts/check-openapi.py +221 -0
- package/templates/scripts/check-rdb-structured-queries.sh +98 -0
- package/templates/scripts/check-repository-transactions.sh +100 -0
- package/templates/scripts/check-runbooks.sh +229 -0
- package/templates/scripts/check-skill-router-sync.py +331 -0
- package/templates/scripts/check-skills.sh +243 -0
- package/templates/scripts/docs-audit-to-issues.py +373 -0
- package/templates/scripts/docs-verification-reminder.sh +63 -0
- package/templates/scripts/find-ci-failures.sh +133 -0
- package/templates/scripts/find-stale-mrs.sh +72 -0
- package/templates/scripts/gen-adr-index.sh +122 -0
- package/templates/scripts/open-mr.sh +536 -0
- package/templates/scripts/regen-agent-md.sh +149 -0
- package/templates/scripts/run-mr-hygiene.sh +140 -0
- package/templates/scripts/runbook.sh +91 -0
- package/templates/scripts/self-maintenance-digest.py +125 -0
- package/templates/scripts/send-self-maintenance-lark-card.py +116 -0
- package/templates/scripts/skill-graph.sh +114 -0
- package/templates/scripts/skill-impact.sh +134 -0
- package/templates/scripts/skill-propose.sh +302 -0
- 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())
|