claude-dev-env 2.8.0 → 2.9.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/CLAUDE.md +7 -1
- package/agents/clean-coder.md +9 -19
- package/agents/test_agent_frontmatter.py +26 -0
- package/docs/CODE_RULES.md +4 -2
- package/docs/references/CLAUDE.md +2 -2
- package/docs/references/advisor-tool.md +44 -6
- package/docs/references/team-advisor-skill.md +14 -8
- package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -0
- package/output-styles/CLAUDE.md +17 -0
- package/output-styles/caveman-agent.md +37 -0
- package/package.json +2 -1
- package/rules/code-standards.md +33 -7
- package/rules/eli11-replies.md +1 -1
- package/scripts/CLAUDE.md +2 -2
- package/scripts/dev_env_scripts_constants/CLAUDE.md +1 -1
- package/scripts/dev_env_scripts_constants/grok_run_ledger_constants.py +50 -0
- package/scripts/dev_env_scripts_constants/grok_worker_constants.py +104 -0
- package/scripts/grok_patch_artifacts.py +123 -0
- package/scripts/grok_run_ledger.py +318 -0
- package/scripts/spawn_grok_batch.py +553 -9
- package/scripts/test_grok_patch_artifacts.py +82 -0
- package/scripts/test_grok_run_ledger.py +116 -0
- package/scripts/test_spawn_grok_batch.py +295 -0
- package/skills/CLAUDE.md +4 -2
- package/skills/_shared/CLAUDE.md +37 -4
- package/skills/_shared/advisor/CLAUDE.md +9 -0
- package/skills/_shared/advisor/advisor-protocol.md +5 -0
- package/skills/_shared/advisor/scripts/README.md +9 -0
- package/skills/_shared/end-of-run-gotcha-recommendations.md +156 -0
- package/skills/_shared/pr-loop/CLAUDE.md +18 -1
- package/skills/_shared/pr-loop/audit-contract.md +5 -0
- package/skills/_shared/pr-loop/audit-reply-template.md +5 -0
- package/skills/_shared/pr-loop/code-rules-gate.md +5 -0
- package/skills/_shared/pr-loop/fix-protocol.md +5 -0
- package/skills/_shared/pr-loop/gh-payloads.md +5 -0
- package/skills/_shared/pr-loop/post-audit-thread-contract.md +5 -0
- package/skills/_shared/pr-loop/precatch-rubric.md +5 -0
- package/skills/_shared/pr-loop/scripts/CLAUDE.md +8 -1
- package/skills/_shared/pr-loop/scripts/RUNTIME_SCRIPTS.md +29 -0
- package/skills/_shared/pr-loop/state-schema.md +5 -0
- package/skills/_shared/pr-loop/worker-spawn.md +5 -0
- package/skills/e-code-review/SKILL.md +6 -1
- package/skills/e-code-review/reference/runner-selection.md +40 -0
- package/skills/e-code-review/scripts/e_code_review_scripts_constants/__init__.py +1 -0
- package/skills/e-code-review/scripts/e_code_review_scripts_constants/grok_code_review_constants.py +55 -0
- package/skills/e-code-review/scripts/grok_code_review.py +221 -0
- package/skills/e-code-review/scripts/test_grok_code_review.py +212 -0
- package/skills/grok-spawn/SKILL.md +5 -0
- package/skills/orchestrator/SKILL.md +5 -0
- package/skills/task-build/reference/tool-routing.md +3 -0
- package/skills/team-advisor/SKILL.md +23 -44
- package/system-prompts/software-engineer.xml +6 -3
- package/skills/test_markdown_link_integrity.py +0 -107
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Behavioral tests for Grok medium-review discovery and verification."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
_SCRIPTS = Path(__file__).resolve().parent
|
|
11
|
+
if str(_SCRIPTS) not in sys.path:
|
|
12
|
+
sys.path.insert(0, str(_SCRIPTS))
|
|
13
|
+
|
|
14
|
+
from e_code_review_scripts_constants.grok_code_review_constants import ( # noqa: E402
|
|
15
|
+
ALL_MEDIUM_FINDER_ANGLES,
|
|
16
|
+
MEDIUM_REVIEW_FINDER_COUNT,
|
|
17
|
+
VERDICT_CONFIRMED,
|
|
18
|
+
VERDICT_PLAUSIBLE,
|
|
19
|
+
VERDICT_REFUTED,
|
|
20
|
+
)
|
|
21
|
+
from grok_code_review import ( # noqa: E402
|
|
22
|
+
FinderCandidate,
|
|
23
|
+
deduplicate_candidates,
|
|
24
|
+
require_exact_finder_set,
|
|
25
|
+
retain_verified_findings,
|
|
26
|
+
run_medium_review,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _candidate(
|
|
31
|
+
angle: str,
|
|
32
|
+
*,
|
|
33
|
+
file_path: str = "a.py",
|
|
34
|
+
line_number: int = 1,
|
|
35
|
+
mechanism: str = "m",
|
|
36
|
+
scenario: str = "short",
|
|
37
|
+
suffix: str = "",
|
|
38
|
+
head: str = "head1",
|
|
39
|
+
) -> FinderCandidate:
|
|
40
|
+
token = suffix or angle
|
|
41
|
+
return FinderCandidate(
|
|
42
|
+
angle=angle,
|
|
43
|
+
file_path=file_path,
|
|
44
|
+
line_number=line_number,
|
|
45
|
+
mechanism=mechanism,
|
|
46
|
+
scenario=scenario,
|
|
47
|
+
worktree_path=f"/wt/{token}",
|
|
48
|
+
leader_socket=f"sock-{token}",
|
|
49
|
+
advisor_session_id=f"adv-{token}",
|
|
50
|
+
reviewed_head=head,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_require_exact_finder_set_accepts_all_angles() -> None:
|
|
55
|
+
require_exact_finder_set(ALL_MEDIUM_FINDER_ANGLES)
|
|
56
|
+
with pytest.raises(ValueError):
|
|
57
|
+
require_exact_finder_set(ALL_MEDIUM_FINDER_ANGLES[:-1])
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_deduplicate_keeps_most_concrete_scenario() -> None:
|
|
61
|
+
short = _candidate("correctness", scenario="short")
|
|
62
|
+
long = _candidate("security", scenario="much more concrete failure path")
|
|
63
|
+
# same key different angles - force same file/line/mech
|
|
64
|
+
short = FinderCandidate(
|
|
65
|
+
angle="correctness",
|
|
66
|
+
file_path="a.py",
|
|
67
|
+
line_number=3,
|
|
68
|
+
mechanism="null",
|
|
69
|
+
scenario="x",
|
|
70
|
+
worktree_path="/wt/a",
|
|
71
|
+
leader_socket="s1",
|
|
72
|
+
advisor_session_id="a1",
|
|
73
|
+
reviewed_head="h",
|
|
74
|
+
)
|
|
75
|
+
long = FinderCandidate(
|
|
76
|
+
angle="security",
|
|
77
|
+
file_path="a.py",
|
|
78
|
+
line_number=3,
|
|
79
|
+
mechanism="null",
|
|
80
|
+
scenario="concrete long scenario",
|
|
81
|
+
worktree_path="/wt/b",
|
|
82
|
+
leader_socket="s2",
|
|
83
|
+
advisor_session_id="a2",
|
|
84
|
+
reviewed_head="h",
|
|
85
|
+
)
|
|
86
|
+
kept = deduplicate_candidates([short, long])
|
|
87
|
+
assert len(kept) == 1
|
|
88
|
+
assert kept[0].scenario == "concrete long scenario"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_run_medium_review_happy_path() -> None:
|
|
92
|
+
head = "abc123"
|
|
93
|
+
all_finders = [
|
|
94
|
+
_candidate(each_angle, head=head, suffix=each_angle)
|
|
95
|
+
for each_angle in ALL_MEDIUM_FINDER_ANGLES
|
|
96
|
+
]
|
|
97
|
+
# make unique keys per angle
|
|
98
|
+
all_finders = [
|
|
99
|
+
FinderCandidate(
|
|
100
|
+
angle=each_angle,
|
|
101
|
+
file_path=f"{each_angle}.py",
|
|
102
|
+
line_number=1,
|
|
103
|
+
mechanism="m",
|
|
104
|
+
scenario=f"scenario {each_angle}",
|
|
105
|
+
worktree_path=f"/wt/{each_angle}",
|
|
106
|
+
leader_socket=f"sock-{each_angle}",
|
|
107
|
+
advisor_session_id=f"adv-{each_angle}",
|
|
108
|
+
reviewed_head=head,
|
|
109
|
+
)
|
|
110
|
+
for each_angle in ALL_MEDIUM_FINDER_ANGLES
|
|
111
|
+
]
|
|
112
|
+
verdicts = {
|
|
113
|
+
(f"{each}.py", 1, "m"): VERDICT_CONFIRMED
|
|
114
|
+
for each in ALL_MEDIUM_FINDER_ANGLES
|
|
115
|
+
}
|
|
116
|
+
severities = {
|
|
117
|
+
(f"{each}.py", 1, "m"): "medium" for each in ALL_MEDIUM_FINDER_ANGLES
|
|
118
|
+
}
|
|
119
|
+
# refute one
|
|
120
|
+
first = ALL_MEDIUM_FINDER_ANGLES[0]
|
|
121
|
+
verdicts[(f"{first}.py", 1, "m")] = VERDICT_REFUTED
|
|
122
|
+
batch = run_medium_review(
|
|
123
|
+
target_head=head,
|
|
124
|
+
diff_base="base",
|
|
125
|
+
all_finder_candidates=all_finders,
|
|
126
|
+
verdict_by_key=verdicts,
|
|
127
|
+
severity_by_key=severities,
|
|
128
|
+
live_head=head,
|
|
129
|
+
)
|
|
130
|
+
assert batch.is_rejected is False
|
|
131
|
+
assert len(batch.all_finder_angles) == MEDIUM_REVIEW_FINDER_COUNT
|
|
132
|
+
assert all(each.reviewed_head == head for each in batch.all_retained_findings)
|
|
133
|
+
assert all(each.verdict in {VERDICT_CONFIRMED, VERDICT_PLAUSIBLE} for each in batch.all_retained_findings)
|
|
134
|
+
assert len(batch.all_retained_findings) == MEDIUM_REVIEW_FINDER_COUNT - 1
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def test_head_drift_rejects_batch() -> None:
|
|
138
|
+
head = "h1"
|
|
139
|
+
all_finders = [
|
|
140
|
+
FinderCandidate(
|
|
141
|
+
angle=each_angle,
|
|
142
|
+
file_path=f"{each_angle}.py",
|
|
143
|
+
line_number=1,
|
|
144
|
+
mechanism="m",
|
|
145
|
+
scenario="s",
|
|
146
|
+
worktree_path=f"/wt/{each_angle}",
|
|
147
|
+
leader_socket=f"sock-{each_angle}",
|
|
148
|
+
advisor_session_id=f"adv-{each_angle}",
|
|
149
|
+
reviewed_head=head,
|
|
150
|
+
)
|
|
151
|
+
for each_angle in ALL_MEDIUM_FINDER_ANGLES
|
|
152
|
+
]
|
|
153
|
+
batch = run_medium_review(
|
|
154
|
+
target_head=head,
|
|
155
|
+
diff_base="b",
|
|
156
|
+
all_finder_candidates=all_finders,
|
|
157
|
+
verdict_by_key={},
|
|
158
|
+
severity_by_key={},
|
|
159
|
+
live_head="other",
|
|
160
|
+
)
|
|
161
|
+
assert batch.is_rejected is True
|
|
162
|
+
assert batch.rejection_reason == "head_drift"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def test_advisor_blocked_rejects_batch() -> None:
|
|
166
|
+
head = "h1"
|
|
167
|
+
all_finders = [
|
|
168
|
+
FinderCandidate(
|
|
169
|
+
angle=each_angle,
|
|
170
|
+
file_path=f"{each_angle}.py",
|
|
171
|
+
line_number=1,
|
|
172
|
+
mechanism="m",
|
|
173
|
+
scenario="s",
|
|
174
|
+
worktree_path=f"/wt/{each_angle}",
|
|
175
|
+
leader_socket=f"sock-{each_angle}",
|
|
176
|
+
advisor_session_id=f"adv-{each_angle}",
|
|
177
|
+
reviewed_head=head,
|
|
178
|
+
)
|
|
179
|
+
for each_angle in ALL_MEDIUM_FINDER_ANGLES
|
|
180
|
+
]
|
|
181
|
+
batch = run_medium_review(
|
|
182
|
+
target_head=head,
|
|
183
|
+
diff_base="b",
|
|
184
|
+
all_finder_candidates=all_finders,
|
|
185
|
+
verdict_by_key={},
|
|
186
|
+
severity_by_key={},
|
|
187
|
+
live_head=head,
|
|
188
|
+
is_any_advisor_blocked=True,
|
|
189
|
+
)
|
|
190
|
+
assert batch.is_rejected is True
|
|
191
|
+
assert batch.rejection_reason == "advisor_blocked"
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def test_retain_verified_findings_keeps_confirmed() -> None:
|
|
195
|
+
candidate = FinderCandidate(
|
|
196
|
+
angle="correctness",
|
|
197
|
+
file_path="a.py",
|
|
198
|
+
line_number=2,
|
|
199
|
+
mechanism="null",
|
|
200
|
+
scenario="boom",
|
|
201
|
+
worktree_path="/wt/x",
|
|
202
|
+
leader_socket="s",
|
|
203
|
+
advisor_session_id="a",
|
|
204
|
+
reviewed_head="h",
|
|
205
|
+
)
|
|
206
|
+
kept = retain_verified_findings(
|
|
207
|
+
all_candidates=[candidate],
|
|
208
|
+
verdict_by_key={("a.py", 2, "null"): VERDICT_CONFIRMED},
|
|
209
|
+
severity_by_key={("a.py", 2, "null"): "high"},
|
|
210
|
+
)
|
|
211
|
+
assert len(kept) == 1
|
|
212
|
+
assert kept[0].verdict == VERDICT_CONFIRMED
|
|
@@ -231,3 +231,8 @@ Sibling skill: `/grokify` for a single paste-ready interactive Grok Build handof
|
|
|
231
231
|
|
|
232
232
|
- `SKILL.md` — hub and process.
|
|
233
233
|
- `reference/` — brief templates and flag profiles.
|
|
234
|
+
|
|
235
|
+
## Worker advisors
|
|
236
|
+
|
|
237
|
+
Each grok worker binds a unique Opus-high dvisor_session_id through the lead-supplied dvisor.launcher in the batch spec (committed default is a placeholder). Reports require the same handle\'s ENDORSE (or bounded CORRECTION/PLAN then ENDORSE); STOP or malformed signals end as advisor_blocked.
|
|
238
|
+
|
|
@@ -358,3 +358,8 @@ no open work remains, run `set --status done` before any re-arm attempt.
|
|
|
358
358
|
- `scripts/` — deterministic status_gate.
|
|
359
359
|
- Advisor policy:
|
|
360
360
|
[`_shared/advisor/advisor-protocol.md`](../../_shared/advisor/advisor-protocol.md).
|
|
361
|
+
|
|
362
|
+
## File-backed run ledger
|
|
363
|
+
|
|
364
|
+
When host task tools are absent, reconcile delegated work through `scripts/grok_run_ledger.py` under the run-state directory (stable task ids, one live owner, unique advisor sessions, dependency blocking, snapshot-drift reopening).
|
|
365
|
+
|
|
@@ -17,3 +17,6 @@ Do not invent a tool name. Tool availability means the callable tool is exposed
|
|
|
17
17
|
## Reporting
|
|
18
18
|
|
|
19
19
|
State the selected tool, the number of new items, the number already tracked, and the new task subjects. If the fallback path was used, name the unavailable preferred tools briefly.
|
|
20
|
+
|
|
21
|
+
4. **File ledger fallback** — when no host task/plan tool is exposed, register work in the durable `grok_run_ledger` under the run-state directory so orchestration stays reconcilable across hosts.
|
|
22
|
+
|
|
@@ -2,63 +2,42 @@
|
|
|
2
2
|
name: team-advisor
|
|
3
3
|
description: >-
|
|
4
4
|
Spawn one warm session-advisor at the strongest reachable tier and consult
|
|
5
|
-
it before
|
|
5
|
+
it before substantive work, completion, commits, or when stuck. Triggers:
|
|
6
6
|
'team-advisor', 'team advisor', 'second opinion', 'advisor', 'consult',
|
|
7
7
|
'verify', 'validate', 'commit', 'push'.
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# Team Advisor
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
One warm advisor at the strongest tier this session can reach. This session is the sole consumer.
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
## Refs
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
**
|
|
19
|
-
[
|
|
20
|
-
|
|
21
|
-
walk until the host is known.
|
|
22
|
-
|
|
23
|
-
This session is the shared advisor's sole consumer, so its model floor is
|
|
24
|
-
simply this session's own tier — no routing table to take a max against.
|
|
16
|
+
| Doc | Holds |
|
|
17
|
+
|---|---|
|
|
18
|
+
| [`docs/references/advisor-tool.md`](../../docs/references/advisor-tool.md) | **Consult cadence and weight** — when to call, hard rule before first write, how to treat advice. Read this for every consult. |
|
|
19
|
+
| [`~/.claude/_shared/advisor/advisor-protocol.md`](../../_shared/advisor/advisor-protocol.md) | **Bind and lifecycle** — host detect, model floor, warm-up or CLI bind, charter, drift re-bind, CLI fallback, executor paste blocks. |
|
|
20
|
+
| [`agents/session-advisor.md`](../../agents/session-advisor.md) | **Reply contract** — ENDORSE / CORRECTION / PLAN / STOP; SendMessage only. |
|
|
25
21
|
|
|
26
|
-
|
|
27
|
-
warm-up spawn and charter, the consult format and cadence, drift-respawn, and
|
|
28
|
-
the CLI fallback — using `team-advisor-agent` as the name and this session as
|
|
29
|
-
the only consumer (skip the "who you are and your assignment" opener in each
|
|
30
|
-
consult; a single-consumer session doesn't need it). A **Fable**-tier attempt
|
|
31
|
-
carries the exact token `FABLE-SPAWN-AUTHORIZED` in its spawn prompt, as the
|
|
32
|
-
protocol's warm-up rule states; `hooks/blocking/fable_spawn_gate.py` denies a
|
|
33
|
-
fable spawn whose prompt lacks it.
|
|
22
|
+
## Bind
|
|
34
23
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
reply, fail closed and report to the user — do **not** answer ENDORSE /
|
|
42
|
-
CORRECTION / PLAN / STOP as this third-party session.
|
|
24
|
+
1. Detect the host profile first (protocol **Host profiles**), then walk the model floor.
|
|
25
|
+
2. Floor: this session's own tier on Claude; Opus floor with Fable first on a third-party host.
|
|
26
|
+
3. Name: `team-advisor-agent` on Claude (Agent spawn of `session-advisor`); one CLI `session_id` on a third-party host via the protocol Claude-chain.
|
|
27
|
+
4. A Fable-tier spawn or re-spawn carries the exact token `FABLE-SPAWN-AUTHORIZED` in its prompt (protocol warm-up; `fable_spawn_gate` requires it).
|
|
28
|
+
5. Skip the multi-consumer "who you are" opener — sole consumer.
|
|
29
|
+
6. When the bind or reply path fails, fail closed and report to the user. On a third-party host, only the bound Claude advisor issues ENDORSE / CORRECTION / PLAN / STOP.
|
|
43
30
|
|
|
44
|
-
|
|
31
|
+
Full walk, charter, consult message shape, and drift re-bind live in the protocol.
|
|
45
32
|
|
|
46
|
-
|
|
47
|
-
`session_id` on a third-party host), owned by this session for its whole lifecycle
|
|
48
|
-
(spawn or CLI bind, drift re-bind, shutdown) — see
|
|
49
|
-
[`_shared/advisor/advisor-protocol.md`](../../_shared/advisor/advisor-protocol.md).
|
|
50
|
-
- Never bind the advisor, or its CLI path, at a tier below the protocol floor
|
|
51
|
-
for this host (Claude: this session's own tier; a third-party host: Opus floor
|
|
52
|
-
with Fable first).
|
|
53
|
-
- The advisor only answers. It never edits a file, never runs a build or
|
|
54
|
-
test, and never posts anything on the session's behalf.
|
|
33
|
+
## Consult
|
|
55
34
|
|
|
56
|
-
|
|
35
|
+
Follow **When to call**, **Hard rule**, and **How to treat advice** in `advisor-tool.md`.
|
|
57
36
|
|
|
58
|
-
|
|
59
|
-
|---|---|
|
|
60
|
-
| `SKILL.md` | Pointer to the shared advisor protocol; this session's consumer-specific wiring and constraints. |
|
|
37
|
+
Each brief: delta since last consult, live decision or blocker, paths or excerpts needed. Protocol owns the full consult format.
|
|
61
38
|
|
|
62
|
-
##
|
|
39
|
+
## Constraints
|
|
63
40
|
|
|
64
|
-
-
|
|
41
|
+
- One bind per session; this session owns spawn or CLI bind, drift re-bind, and shutdown.
|
|
42
|
+
- Bind at or above the protocol floor for this host.
|
|
43
|
+
- The advisor only answers (messaging); the session runs tools and posts.
|
|
@@ -90,8 +90,10 @@
|
|
|
90
90
|
</problem_solving>
|
|
91
91
|
|
|
92
92
|
<code_quality>
|
|
93
|
-
|
|
94
|
-
|
|
93
|
+
Canonical code-quality policy is repository-root AGENTS.md (installed review contract).
|
|
94
|
+
~/.claude/docs/CODE_RULES.md is the compact projection of AGENTS.md for generation load —
|
|
95
|
+
when the two disagree, AGENTS.md wins. The checklist below is a short reminder, not a
|
|
96
|
+
second canonical source.
|
|
95
97
|
|
|
96
98
|
Naming — full words only:
|
|
97
99
|
- Use "context" over "ctx", "configuration" over "cfg", "message" over "msg",
|
|
@@ -109,7 +111,8 @@
|
|
|
109
111
|
Types — complete coverage required:
|
|
110
112
|
- Every parameter and return value carries an explicit type hint
|
|
111
113
|
- Replace Any with the most specific type available
|
|
112
|
-
-
|
|
114
|
+
- Prefer a real type over `# type: ignore`; when an ignore is required, add a second
|
|
115
|
+
trailing `#` justification of at least five characters (AGENTS Types)
|
|
113
116
|
|
|
114
117
|
Configuration — one source of truth:
|
|
115
118
|
- Every constant lives in config/ only
|
|
@@ -1,107 +0,0 @@
|
|
|
1
|
-
"""Link-integrity check for the skills tree and shared protocol docs.
|
|
2
|
-
|
|
3
|
-
Walks every markdown file under ``skills/``, ``_shared/pr-loop/``, and
|
|
4
|
-
``_shared/advisor/`` and resolves each relative markdown link target against
|
|
5
|
-
the linking file's directory. A link whose target file or directory does not
|
|
6
|
-
exist on disk is reported with its source file and line number, and the test
|
|
7
|
-
fails.
|
|
8
|
-
"""
|
|
9
|
-
|
|
10
|
-
from __future__ import annotations
|
|
11
|
-
|
|
12
|
-
import re
|
|
13
|
-
from pathlib import Path
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
SKILLS_ROOT = Path(__file__).parent
|
|
17
|
-
SHARED_PR_LOOP_ROOT = SKILLS_ROOT.parent / "_shared" / "pr-loop"
|
|
18
|
-
SHARED_ADVISOR_ROOT = SKILLS_ROOT.parent / "_shared" / "advisor"
|
|
19
|
-
|
|
20
|
-
MARKDOWN_LINK_PATTERN = re.compile(r"\]\(([^)\s]+)\)")
|
|
21
|
-
FENCE_MARKER = "```"
|
|
22
|
-
|
|
23
|
-
SKIPPED_TARGET_PREFIXES = (
|
|
24
|
-
"http://",
|
|
25
|
-
"https://",
|
|
26
|
-
"mailto:",
|
|
27
|
-
"#",
|
|
28
|
-
"/",
|
|
29
|
-
"$",
|
|
30
|
-
"~",
|
|
31
|
-
"<",
|
|
32
|
-
)
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
def _iter_markdown_files() -> list[Path]:
|
|
36
|
-
"""List every markdown file the integrity check covers.
|
|
37
|
-
|
|
38
|
-
Returns:
|
|
39
|
-
All ``.md`` files under the skills tree, the shared pr-loop tree, and
|
|
40
|
-
the shared advisor tree.
|
|
41
|
-
"""
|
|
42
|
-
all_markdown_files = sorted(SKILLS_ROOT.rglob("*.md"))
|
|
43
|
-
all_markdown_files.extend(sorted(SHARED_PR_LOOP_ROOT.rglob("*.md")))
|
|
44
|
-
all_markdown_files.extend(sorted(SHARED_ADVISOR_ROOT.rglob("*.md")))
|
|
45
|
-
return all_markdown_files
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
def _is_checkable_target(link_target: str) -> bool:
|
|
49
|
-
"""Decide whether a markdown link target names a local relative path.
|
|
50
|
-
|
|
51
|
-
Args:
|
|
52
|
-
link_target: The raw text between ``](`` and ``)`` in a markdown link.
|
|
53
|
-
|
|
54
|
-
Returns:
|
|
55
|
-
True when the target is a relative filesystem path this check can
|
|
56
|
-
resolve; False for URLs, anchors, absolute paths, environment-variable
|
|
57
|
-
paths, home-relative paths, and angle-bracket placeholders.
|
|
58
|
-
"""
|
|
59
|
-
if link_target.startswith(SKIPPED_TARGET_PREFIXES):
|
|
60
|
-
return False
|
|
61
|
-
if "\\" in link_target and link_target.count(":") > 0:
|
|
62
|
-
return False
|
|
63
|
-
return True
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
def _collect_broken_links(markdown_path: Path) -> list[str]:
|
|
67
|
-
"""Resolve every relative link in one markdown file.
|
|
68
|
-
|
|
69
|
-
Fenced code blocks are skipped, and a ``#fragment`` suffix is stripped
|
|
70
|
-
before resolution.
|
|
71
|
-
|
|
72
|
-
Args:
|
|
73
|
-
markdown_path: The markdown file whose links are resolved.
|
|
74
|
-
|
|
75
|
-
Returns:
|
|
76
|
-
One ``file:line -> target`` description per unresolvable link.
|
|
77
|
-
"""
|
|
78
|
-
all_broken_links: list[str] = []
|
|
79
|
-
is_inside_fence = False
|
|
80
|
-
for line_number, each_line in enumerate(
|
|
81
|
-
markdown_path.read_text(encoding="utf-8").splitlines(), start=1
|
|
82
|
-
):
|
|
83
|
-
if each_line.lstrip().startswith(FENCE_MARKER):
|
|
84
|
-
is_inside_fence = not is_inside_fence
|
|
85
|
-
continue
|
|
86
|
-
if is_inside_fence:
|
|
87
|
-
continue
|
|
88
|
-
for each_match in MARKDOWN_LINK_PATTERN.finditer(each_line):
|
|
89
|
-
link_target = each_match.group(1).split("#", 1)[0]
|
|
90
|
-
if not link_target or not _is_checkable_target(link_target):
|
|
91
|
-
continue
|
|
92
|
-
resolved_target = (markdown_path.parent / link_target).resolve()
|
|
93
|
-
if not resolved_target.exists():
|
|
94
|
-
all_broken_links.append(
|
|
95
|
-
f"{markdown_path}:{line_number} -> {each_match.group(1)}"
|
|
96
|
-
)
|
|
97
|
-
return all_broken_links
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
def test_every_relative_markdown_link_resolves() -> None:
|
|
101
|
-
all_broken_links: list[str] = []
|
|
102
|
-
for each_markdown_path in _iter_markdown_files():
|
|
103
|
-
all_broken_links.extend(_collect_broken_links(each_markdown_path))
|
|
104
|
-
broken_link_report = "\n".join(all_broken_links)
|
|
105
|
-
assert not all_broken_links, (
|
|
106
|
-
f"Unresolvable relative markdown links:\n{broken_link_report}"
|
|
107
|
-
)
|