Skip to content

Commit 6467e15

Browse files
fix(port): resolve nested generated resource links (#113)
Amp-Thread-ID: https://ampcode.com/threads/T-019f84ff-bc93-75ca-a36b-4e7561bc3f1d Co-authored-by: Amp <amp@ampcode.com>
1 parent 8ff2e3c commit 6467e15

46 files changed

Lines changed: 96 additions & 77 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,11 @@ capabilities rather than claiming cross-runtime feature parity.
116116

117117
### Fixed
118118

119+
- **Generated nested resource links now resolve from their containing file**
120+
Amp, Codex, Pi, and OpenCode projections compute skill-relative paths from
121+
each Markdown resource directory instead of the skill root, fixing broken
122+
cross-skill and sibling links under nested `references/` directories.
123+
119124
- **Reliable behavioral trigger gates and routing boundaries** — deterministic
120125
structural evals no longer depend on ignored local result caches, while
121126
`make eval-full` runs a fresh Claude Haiku 4.5 gate requiring every skill to

scripts/generated_target_snapshots.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,22 +4,22 @@
44
"amp": {
55
"entries": 379,
66
"files": 251,
7-
"sha256": "f233f4ba7205bdfd9cb9f8d15e341a600aa78c9a85dfbe5e228232ca4b3ccc59"
7+
"sha256": "6e11b750d92f2fb45fcb996b877e81e7d4baac0a52115f4f250efd3d1fbe2100"
88
},
99
"codex": {
1010
"entries": 385,
1111
"files": 254,
12-
"sha256": "210763516017a01a72d3d229702c57263a00956fbb87f05c6ff34554e3ee8311"
12+
"sha256": "7d3384f5164dea6204d76229eeb42654d6352151784714452d8973ff10879d85"
1313
},
1414
"opencode": {
1515
"entries": 379,
1616
"files": 251,
17-
"sha256": "a429a213023ced972b20f0bc592bf9346c55b7dbfd14dbf751dd4f91f941564a"
17+
"sha256": "4952d736ff166d09ea6f2248035e207098ac4648372f75036330a7f125af684a"
1818
},
1919
"pi": {
2020
"entries": 380,
2121
"files": 252,
22-
"sha256": "e670441b98013c6f0ce57fb7591a4c62730f277338f9cf4dd433a30806ad3ea6"
22+
"sha256": "8c87793a75c47958091d71b9a8f89a543682b7a90f8dc35e78a9c984143b8678"
2323
}
2424
}
2525
}

scripts/port_lib/amp.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ def _target_relative_path(
9494
source_path: Path,
9595
current: SkillSource,
9696
skills: list[SkillSource],
97+
source_file: Path,
9798
) -> str:
9899
"""Map a canonical resource path to its generated relative location."""
99100
by_source_dir = {skill.source_dir.resolve(): skill for skill in skills}
@@ -115,7 +116,8 @@ def _target_relative_path(
115116

116117
relative_resource = resolved.relative_to(owner.source_dir.resolve())
117118
generated_resource = Path(owner.target_name) / relative_resource
118-
generated_current = Path(current.target_name)
119+
source_relative = source_file.resolve().relative_to(current.source_dir.resolve())
120+
generated_current = Path(current.target_name) / source_relative.parent
119121
return Path(os.path.relpath(generated_resource, generated_current)).as_posix()
120122

121123

@@ -137,7 +139,7 @@ def replace_skill_dir(match: re.Match[str]) -> str:
137139
raise ValueError(
138140
f"{source_file}: missing referenced resource {source_path}"
139141
)
140-
return _target_relative_path(source_path, current, skills)
142+
return _target_relative_path(source_path, current, skills, source_file)
141143

142144
def replace_plugin_root(match: re.Match[str]) -> str:
143145
raw_path = match.group(1)
@@ -153,19 +155,19 @@ def replace_plugin_root(match: re.Match[str]) -> str:
153155
raise ValueError(
154156
f"{source_file}: unsupported CLAUDE_PLUGIN_ROOT resource {source_path}"
155157
)
156-
return _target_relative_path(source_path, current, skills)
158+
return _target_relative_path(source_path, current, skills, source_file)
157159

158160
def replace_bare_sibling(match: re.Match[str]) -> str:
159161
source_path = current.source_dir.parent / match.group(1) / match.group(2)
160162
if "<" in match.group(0) or ">" in match.group(0) or not source_path.exists():
161163
return match.group(0)
162-
return _target_relative_path(source_path, current, skills)
164+
return _target_relative_path(source_path, current, skills, source_file)
163165

164166
def replace_canonical_skill_path(match: re.Match[str]) -> str:
165167
source_path = current.source_dir.parent / match.group(1) / match.group(2)
166168
if "<" in match.group(0) or ">" in match.group(0) or not source_path.exists():
167169
return match.group(0)
168-
return _target_relative_path(source_path, current, skills)
170+
return _target_relative_path(source_path, current, skills, source_file)
169171

170172
def replace_bare_skill_path(match: re.Match[str]) -> str:
171173
source_skill = current.source_dir.parent / match.group(1)
@@ -177,7 +179,7 @@ def replace_bare_skill_path(match: re.Match[str]) -> str:
177179
or not source_path.exists()
178180
):
179181
return match.group(0)
180-
return _target_relative_path(source_path, current, skills)
182+
return _target_relative_path(source_path, current, skills, source_file)
181183

182184
text = SKILL_DIR_TOKEN_RE.sub(replace_skill_dir, text)
183185
text = PLUGIN_ROOT_TOKEN_RE.sub(replace_plugin_root, text)

scripts/port_lib/codex.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1358,6 +1358,7 @@ def _target_relative_path(
13581358
source_path: Path,
13591359
current: SkillSource,
13601360
skills: list[SkillSource],
1361+
source_file: Path,
13611362
) -> str:
13621363
resolved = source_path.resolve()
13631364
owner = next(
@@ -1378,8 +1379,10 @@ def _target_relative_path(
13781379
generated_resource = (
13791380
Path(owner.target_name) / resolved.relative_to(owner.source_dir.resolve())
13801381
)
1382+
source_relative = source_file.resolve().relative_to(current.source_dir.resolve())
1383+
generated_current = Path(current.target_name) / source_relative.parent
13811384
return Path(
1382-
os.path.relpath(generated_resource, Path(current.target_name))
1385+
os.path.relpath(generated_resource, generated_current)
13831386
).as_posix()
13841387

13851388

@@ -1398,7 +1401,7 @@ def replace_skill_dir(match: re.Match[str]) -> str:
13981401
source_path = current.source_dir / raw_path
13991402
if not source_path.exists():
14001403
raise ValueError(f"{source_file}: missing referenced resource {source_path}")
1401-
return _target_relative_path(source_path, current, skills)
1404+
return _target_relative_path(source_path, current, skills, source_file)
14021405

14031406
def replace_plugin_root(match: re.Match[str]) -> str:
14041407
raw_path = match.group(1)
@@ -1411,19 +1414,19 @@ def replace_plugin_root(match: re.Match[str]) -> str:
14111414
raise ValueError(
14121415
f"{source_file}: unsupported CLAUDE_PLUGIN_ROOT resource {source_path}"
14131416
)
1414-
return _target_relative_path(source_path, current, skills)
1417+
return _target_relative_path(source_path, current, skills, source_file)
14151418

14161419
def replace_bare_sibling(match: re.Match[str]) -> str:
14171420
source_path = current.source_dir.parent / match.group(1) / match.group(2)
14181421
if "<" in match.group(0) or ">" in match.group(0) or not source_path.exists():
14191422
return match.group(0)
1420-
return _target_relative_path(source_path, current, skills)
1423+
return _target_relative_path(source_path, current, skills, source_file)
14211424

14221425
def replace_canonical_skill_path(match: re.Match[str]) -> str:
14231426
source_path = current.source_dir.parent / match.group(1) / match.group(2)
14241427
if "<" in match.group(0) or ">" in match.group(0) or not source_path.exists():
14251428
return match.group(0)
1426-
return _target_relative_path(source_path, current, skills)
1429+
return _target_relative_path(source_path, current, skills, source_file)
14271430

14281431
def replace_bare_skill_path(match: re.Match[str]) -> str:
14291432
source_skill = current.source_dir.parent / match.group(1)
@@ -1435,7 +1438,7 @@ def replace_bare_skill_path(match: re.Match[str]) -> str:
14351438
or not source_path.exists()
14361439
):
14371440
return match.group(0)
1438-
return _target_relative_path(source_path, current, skills)
1441+
return _target_relative_path(source_path, current, skills, source_file)
14391442

14401443
text = SKILL_DIR_TOKEN_RE.sub(replace_skill_dir, text)
14411444
text = PLUGIN_ROOT_TOKEN_RE.sub(replace_plugin_root, text)

scripts/tests/test_amp.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ def test_build_rewrites_verified_bare_resource_paths(tmp_path) -> None:
132132
assert "../phx-second/scripts/run.sh" in generated
133133
assert "plugins/elixir-phoenix" not in generated
134134
nested = (output / "phx-first" / "references" / "nested.md").read_text()
135-
assert "../phx-second/references/guide.md" in nested
135+
assert "../../phx-second/references/guide.md" in nested
136136

137137

138138
def test_build_rejects_name_collisions_before_replacing_output(tmp_path) -> None:

scripts/tests/test_codex.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,12 +248,21 @@ def test_rewrites_cross_skill_resources_and_rejects_missing_or_escaping_paths(
248248
second = _write_skill(plugin, "second", "phx:second")
249249
(second / "references").mkdir()
250250
(second / "references" / "guide.md").write_text("Guide\n", encoding="utf-8")
251+
first_references = plugin / "skills" / "first" / "references"
252+
first_references.mkdir()
253+
(first_references / "nested.md").write_text(
254+
"Read `second/references/guide.md`.\n",
255+
encoding="utf-8",
256+
)
251257

252258
output = tmp_path / "codex"
253259
codex.build(plugin, output)
254260
assert "../phx-second/references/guide.md" in (
255261
output / "skills" / "phx-first" / "SKILL.md"
256262
).read_text()
263+
assert "../../phx-second/references/guide.md" in (
264+
output / "skills" / "phx-first" / "references" / "nested.md"
265+
).read_text()
257266

258267
missing = tmp_path / "missing"
259268
broken = _write_skill(

targets/amp/skills/phx-compound/references/compound-workflow.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,14 +93,14 @@ Gathering strategy:
9393
9494
### Phase 4: Schema Validation
9595
96-
Validate frontmatter against `../compound-docs/references/schema.md`.
96+
Validate frontmatter against `../../compound-docs/references/schema.md`.
9797
Use suggested values when they fit; create descriptive labels when
9898
they don't. Only `severity` is a strict enum.
9999

100100
### Phase 5: File Creation
101101

102102
Create in `.claude/solutions/{category}/` using the template from
103-
`../compound-docs/references/resolution-template.md`.
103+
`../../compound-docs/references/resolution-template.md`.
104104

105105
Filename: `{sanitized-symptom}-{module}-{YYYYMMDD}.md`
106106

targets/amp/skills/phx-deps-audit/references/cassettes.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ endpoints those rules consume.
3030
## Cassette layout
3131

3232
```text
33-
test-assets/hex-api-cassettes
33+
../test-assets/hex-api-cassettes
3434
├── phoenix.packages.json
3535
├── phoenix.releases.1.7.20.json
3636
├── phoenix.releases.1.7.21.json
@@ -98,7 +98,7 @@ Mirrors `hex.pm` API verbatim (only fields we consume):
9898
# Helper script — capture.sh
9999
pkg=$1
100100
ver=$2
101-
out_dir=test-assets/hex-api-cassettes
101+
out_dir=../test-assets/hex-api-cassettes
102102

103103
curl -fsSL "https://hex.pm/api/packages/${pkg}" \
104104
| jq '.' > "${out_dir}/${pkg}.packages.json"
@@ -238,8 +238,8 @@ When a user adds a `hex_vet.exs` entry for a package without a
238238
cassette, document the manual flow in the PR:
239239

240240
```bash
241-
bash priv/cassettes/capture.sh <pkg> <ver>
242-
git add test-assets/hex-api-cassettes
241+
bash ../priv/cassettes/capture.sh <pkg> <ver>
242+
git add ../test-assets/hex-api-cassettes
243243
```
244244

245245
Reviewers should diff the cassette body and the `_meta.json` SHA

targets/amp/skills/phx-deps-audit/references/differential.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ benefit from differential mode (the package itself is the unit).
9898
Once both NDJSON streams exist, the skill body invokes:
9999

100100
```bash
101-
python3 "scripts/diff_findings.py" \
101+
python3 "../scripts/diff_findings.py" \
102102
--new "${AUDIT_TMPDIR}/findings.jsonl" \
103103
--old "${AUDIT_TMPDIR}/findings.old.jsonl" \
104104
--new-out "${AUDIT_TMPDIR}/new_signals.jsonl" \

targets/amp/skills/phx-deps-audit/references/hook.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ next hook invocation gets a Tier 0 hit.
6969
## Policy enforcement
7070

7171
The hook reads `policy.block_on_unvetted` from `hex_vet.exs` (see
72-
`../phx-deps-vet/references/hex-vet.md` for the tri-mode schema) and gates
72+
`../../phx-deps-vet/references/hex-vet.md` for the tri-mode schema) and gates
7373
findings accordingly:
7474

7575
| Mode | On Tier 1 findings |

0 commit comments

Comments
 (0)