Skip to content

Commit 84bdf8c

Browse files
fix(ce-compound): check in-repo absolute path citations (#1561)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
1 parent 67550d1 commit 84bdf8c

4 files changed

Lines changed: 281 additions & 18 deletions

File tree

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
---
2+
title: "Doc-claims absolute path check - Plan"
3+
type: fix
4+
date: 2026-08-28
5+
origin: "https://github.com/EveryInc/compound-engineering-plugin/issues/1560#issuecomment-5447131411"
6+
artifact_contract: ce-unified-plan/v1
7+
artifact_readiness: implementation-ready
8+
product_contract_source: ce-plan-bootstrap
9+
execution: code
10+
---
11+
12+
# Doc-claims absolute path check - Plan
13+
14+
## Goal Capsule
15+
16+
- **Objective:** A learning that cites a file by absolute path inside the repo gets a real existence check, so a green run cannot mean "checked nothing."
17+
- **Means:** Rewrite an in-repo absolute token to a repo-relative path before the candidacy test (KTD1).
18+
- **Authority:** Issue #1560 and its comment outrank implementation convenience; PR #1552 is complementary and out of scope. Session-settled Key Decisions outrank inferred polish.
19+
- **Execution profile:** One unit, one PR. Test-first on the existing validator suite. Invoke `ce-skill-work` before editing either skill copy.
20+
- **Stop conditions:** Stop and surface if an in-repo rewrite cannot be distinguished from a slash-prefixed URL route without treating the route as a path. Do not expand into fenced-block unmasking or #1552's flag-message work.
21+
- **Tail ownership:** The invoking pipeline (`lfg`) owns simplify, review, commit, PR, and CI.
22+
23+
## Product Contract
24+
25+
### Summary
26+
27+
Check absolute filesystem citations that fall inside the repo. Leave URL routes and out-of-repo slash-prefixed tokens ignored. Apply the same change to both byte-identical validator copies.
28+
29+
Product Contract preservation: N/A (bootstrap).
30+
31+
### Problem Frame
32+
33+
`validate-doc-claims.py` reports `OK` with `0 flags` while checking zero paths whenever a doc cites files by absolute path. The candidacy guard rejects every token that starts with `/`, which is why API routes are ignored and why an in-repo absolute citation never reaches the existence check. House styles that require absolute paths so a learning still resolves after a move then get a silent empty pass.
34+
35+
### Requirements
36+
37+
- R1. A backticked absolute citation that names a path inside the repo is checked the same way as a repo-relative citation of that path.
38+
- R2. A backticked absolute citation that names a missing in-repo path produces a `FLAG path` and a non-zero exit, not `OK`.
39+
- R3. A slash-prefixed URL route remains ignored and does not produce a path flag.
40+
- R4. The two skill copies of the validator stay byte-identical.
41+
42+
### Scope Boundaries
43+
44+
- In: candidacy rewrite for in-repo absolute tokens; both validator copies; the three cases already specified on #1560.
45+
- Out: treating out-of-repo absolute paths as checkable; treating URL routes as paths; unmasking citations inside fenced code blocks; PR #1552's not-found flag wording.
46+
- Deferred to Follow-Up Work: a skill-docs line that fenced-block citations stay unchecked by design. The issue named this as outside the patch.
47+
48+
### Key Decisions
49+
50+
- KD1. Check in-repo absolute filesystem citations; keep URL routes and out-of-repo slash-prefixed tokens ignored. (session-settled: user-directed — chosen over treating every slash-prefixed token as a path: that would flag API routes as missing files.) Governs R1, R2, R3.
51+
- KD2. Change both validator copies together. (session-settled: user-directed — chosen over fixing only one copy: the copies are required to stay identical.) Governs R4.
52+
53+
### Sources
54+
55+
- Issue #1560 and comment `5447131411` (false-pass reproduction and the three test cases).
56+
- `skills/ce-compound/scripts/validate-doc-claims.py` and its byte-identical copy under `skills/ce-compound-refresh/scripts/`.
57+
- `tests/doc-claims-validator.test.ts` already runs every case against both skill directories.
58+
59+
## Planning Contract
60+
61+
### Key Technical Decisions
62+
63+
- KTD1. Before the candidacy test, rewrite only an already-absolute token. Containment is realpath-of-the-token against realpath-of-the-repo-root, with no join through the doc directory; if the relpath stays inside the repo, use that repo-relative path. Relative tokens, including `../` citations, stay unchanged so the existing post-candidacy `../` branch keeps owning them. URL routes stay unchanged so the slash-prefix guard still drops them. Realpath both sides so a host where `/tmp` is a symlink still matches. (session-settled: user-directed — chosen over dropping the slash-prefix guard: that guard is what keeps `/api/...` ignored.) Governs R1, R3.
64+
- KTD2. Add the three #1560 cases inside the existing per-skill loop in `tests/doc-claims-validator.test.ts` rather than a one-copy suite. Governs R2, R4.
65+
66+
### Assumptions
67+
68+
- The script module docstring's "repo-relative paths" bullet should mention in-repo absolute citations so the contract matches behavior. Unvalidated; do not block on it.
69+
- Fenced-code masking stays as designed. The issue asked for a possible docs line, not a code change.
70+
71+
### Patterns to Follow
72+
73+
- Existing `normalize_path` then `is_path_candidate` order in the path-scan loop.
74+
- Existing post-candidacy `../` rewrite stays the sole owner of doc-relative citations. The new rewrite does not reuse that `doc_dir` join.
75+
- `tests/doc-claims-validator.test.ts` fixture `writeRepoDoc` plus `src/real-file.ts` in the scratch repo.
76+
77+
## Implementation Units
78+
79+
### U1. Check in-repo absolute citations
80+
81+
- **Goal:** An in-repo absolute citation is counted and, when missing, flagged; a URL route stays ignored; both copies stay identical.
82+
- **Requirements:** R1, R2, R3, R4; KTD1, KTD2.
83+
- **Dependencies:** none
84+
- **Files:**
85+
- `skills/ce-compound/scripts/validate-doc-claims.py`
86+
- `skills/ce-compound-refresh/scripts/validate-doc-claims.py`
87+
- `tests/doc-claims-validator.test.ts`
88+
- **Approach:**
89+
1. Add the three #1560 cases inside the existing `SKILL_DIRS` loop so they fail on current `main`.
90+
2. Apply KTD1 in both copies so those cases pass and the copies stay byte-identical. Restrict the rewrite to already-absolute tokens; do not join them through the doc directory.
91+
- **Execution note:** Implement the three cases test-first. They are the false-pass proof, not just a checked-count assertion.
92+
- **Patterns to follow:** `writeRepoDoc` / `runValidator` helpers and the per-skill `describe` already in `tests/doc-claims-validator.test.ts`.
93+
- **Test scenarios:**
94+
- Happy path: a doc cites `path.join(repo, "src/real-file.ts")` in backticks; exit 0; stdout does not contain `checked 0 paths`.
95+
- Error path: a doc cites `path.join(repo, "src/does-not-exist.ts")` in backticks; exit 1; stdout contains `FLAG path`.
96+
- Edge: a doc cites `/api/v1/users/me` in backticks; exit 0; stdout contains no `FLAG`.
97+
- **Verification:** Both skill copies produce the same results on those three cases. The two script files remain byte-identical.
98+
99+
## Verification Contract
100+
101+
- Targeted: `bun test tests/doc-claims-validator.test.ts`
102+
- Full suite: `bun run test` (same suite CI runs)
103+
- `release:validate` is not required unless inventory or marketplace metadata changes; this plan does not.
104+
105+
## Definition of Done
106+
107+
- R1–R4 hold on both skill copies.
108+
- The three U1 scenarios fail on current `main` and pass after the rewrite.
109+
- The two `validate-doc-claims.py` files remain byte-identical.
110+
- No change to fenced-block masking or to #1552's flag wording.
111+
- Abandoned-attempt edits are not left in the diff.

skills/ce-compound-refresh/scripts/validate-doc-claims.py

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,15 @@
1414
citations against the repository:
1515
1616
1. Cited repo-relative paths (backticked, containing at least one '/')
17-
exist in the working tree; tokens containing '../' resolve from the
18-
doc's directory (those escaping the repo are skipped). Misses tracked
19-
at HEAD or the upstream default branch still count as real paths and
20-
are classified (deleted/uncommitted vs stale checkout). Tokens
21-
missing everywhere are flagged only when path-shaped; slash-delimited
22-
identifiers (branch names, git refs, provider/model IDs) are skipped.
17+
exist in the working tree, including already-absolute citations that
18+
fall inside the repo (rewritten to repo-relative before candidacy).
19+
Tokens containing '../' resolve from the doc's directory (those
20+
escaping the repo are skipped). Misses tracked at HEAD or the
21+
upstream default branch still count as real paths and are classified
22+
(deleted/uncommitted vs stale checkout). Tokens missing everywhere
23+
are flagged only when path-shaped; slash-delimited identifiers
24+
(branch names, git refs, provider/model IDs) and slash-prefixed
25+
URL routes are skipped.
2326
2. Cited commit SHAs (7-40 hex chars with at least one digit and one
2427
a-f letter) resolve to commits, classified by reachability from
2528
HEAD and the upstream default branch.
@@ -91,10 +94,10 @@ def split_body(text: str) -> tuple[str, int]:
9194
return text, 1
9295

9396

94-
def is_path_candidate(token: str) -> bool:
97+
def is_path_candidate(token: str, *, known_path: bool = False) -> bool:
9598
if any(ch.isspace() for ch in token):
9699
return False
97-
if "/" not in token:
100+
if not known_path and "/" not in token:
98101
return False
99102
if "://" in token or token.startswith(("http", "#", "/", "~")):
100103
return False
@@ -155,6 +158,26 @@ def normalize_path(token: str) -> str:
155158
return token
156159

157160

161+
def strip_repo_prefix(token: str, base: str) -> str:
162+
"""Rewrite an already-absolute path inside the repo to repo-relative.
163+
164+
Relative tokens, URL routes, and out-of-repo absolute paths are
165+
unchanged so the existing candidacy guard still drops them. Realpath
166+
both sides so a host where /tmp is a symlink still matches. A
167+
successful rewrite is slash-normalized so Windows relpath output
168+
stays a candidate.
169+
"""
170+
if not os.path.isabs(token):
171+
return token
172+
try:
173+
rel = os.path.relpath(os.path.realpath(token), os.path.realpath(base))
174+
except ValueError:
175+
return token
176+
if rel == ".." or rel.startswith(".." + os.sep):
177+
return token
178+
return rel.replace("\\", "/")
179+
180+
158181
def main(argv: list[str]) -> int:
159182
if len(argv) != 2:
160183
usage_fail(f"usage: {os.path.basename(argv[0])} <doc-path>")
@@ -234,7 +257,12 @@ def head_has_path(path: str) -> bool:
234257
base = repo_root if in_git else os.getcwd()
235258
for raw in BACKTICK_RE.findall(body):
236259
token = normalize_path(raw)
237-
if not is_path_candidate(token):
260+
rewritten_abs = False
261+
if in_git:
262+
before = token
263+
token = strip_repo_prefix(token, base)
264+
rewritten_abs = token != before
265+
if not is_path_candidate(token, known_path=rewritten_abs):
238266
continue
239267
check = token
240268
if token.startswith("../") or "/../" in token:

skills/ce-compound/scripts/validate-doc-claims.py

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,15 @@
1414
citations against the repository:
1515
1616
1. Cited repo-relative paths (backticked, containing at least one '/')
17-
exist in the working tree; tokens containing '../' resolve from the
18-
doc's directory (those escaping the repo are skipped). Misses tracked
19-
at HEAD or the upstream default branch still count as real paths and
20-
are classified (deleted/uncommitted vs stale checkout). Tokens
21-
missing everywhere are flagged only when path-shaped; slash-delimited
22-
identifiers (branch names, git refs, provider/model IDs) are skipped.
17+
exist in the working tree, including already-absolute citations that
18+
fall inside the repo (rewritten to repo-relative before candidacy).
19+
Tokens containing '../' resolve from the doc's directory (those
20+
escaping the repo are skipped). Misses tracked at HEAD or the
21+
upstream default branch still count as real paths and are classified
22+
(deleted/uncommitted vs stale checkout). Tokens missing everywhere
23+
are flagged only when path-shaped; slash-delimited identifiers
24+
(branch names, git refs, provider/model IDs) and slash-prefixed
25+
URL routes are skipped.
2326
2. Cited commit SHAs (7-40 hex chars with at least one digit and one
2427
a-f letter) resolve to commits, classified by reachability from
2528
HEAD and the upstream default branch.
@@ -91,10 +94,10 @@ def split_body(text: str) -> tuple[str, int]:
9194
return text, 1
9295

9396

94-
def is_path_candidate(token: str) -> bool:
97+
def is_path_candidate(token: str, *, known_path: bool = False) -> bool:
9598
if any(ch.isspace() for ch in token):
9699
return False
97-
if "/" not in token:
100+
if not known_path and "/" not in token:
98101
return False
99102
if "://" in token or token.startswith(("http", "#", "/", "~")):
100103
return False
@@ -155,6 +158,26 @@ def normalize_path(token: str) -> str:
155158
return token
156159

157160

161+
def strip_repo_prefix(token: str, base: str) -> str:
162+
"""Rewrite an already-absolute path inside the repo to repo-relative.
163+
164+
Relative tokens, URL routes, and out-of-repo absolute paths are
165+
unchanged so the existing candidacy guard still drops them. Realpath
166+
both sides so a host where /tmp is a symlink still matches. A
167+
successful rewrite is slash-normalized so Windows relpath output
168+
stays a candidate.
169+
"""
170+
if not os.path.isabs(token):
171+
return token
172+
try:
173+
rel = os.path.relpath(os.path.realpath(token), os.path.realpath(base))
174+
except ValueError:
175+
return token
176+
if rel == ".." or rel.startswith(".." + os.sep):
177+
return token
178+
return rel.replace("\\", "/")
179+
180+
158181
def main(argv: list[str]) -> int:
159182
if len(argv) != 2:
160183
usage_fail(f"usage: {os.path.basename(argv[0])} <doc-path>")
@@ -234,7 +257,12 @@ def head_has_path(path: str) -> bool:
234257
base = repo_root if in_git else os.getcwd()
235258
for raw in BACKTICK_RE.findall(body):
236259
token = normalize_path(raw)
237-
if not is_path_candidate(token):
260+
rewritten_abs = False
261+
if in_git:
262+
before = token
263+
token = strip_repo_prefix(token, base)
264+
rewritten_abs = token != before
265+
if not is_path_candidate(token, known_path=rewritten_abs):
238266
continue
239267
check = token
240268
if token.startswith("../") or "/../" in token:

tests/doc-claims-validator.test.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,102 @@ describe("validate-doc-claims script", () => {
150150
expect(result.stdout).toContain("not found")
151151
})
152152

153+
test("checks an absolute citation that points inside the repo", () => {
154+
const docPath = writeRepoDoc(
155+
"The fix lives in `" + path.join(repo, "src/real-file.ts") + "`.\n",
156+
)
157+
const result = runValidator(skillDir, docPath)
158+
expect(result.code).toBe(0)
159+
expect(result.stdout).not.toContain("checked 0 paths")
160+
})
161+
162+
test("flags a BROKEN absolute citation instead of silently passing", () => {
163+
const docPath = writeRepoDoc(
164+
"The handler is `" +
165+
path.join(repo, "src/does-not-exist.ts") +
166+
"`.\n",
167+
)
168+
const result = runValidator(skillDir, docPath)
169+
expect(result.code).toBe(1)
170+
expect(result.stdout).toContain("FLAG path")
171+
})
172+
173+
test("still ignores a URL route that starts with a slash", () => {
174+
const docPath = writeRepoDoc(
175+
"The probe calls `/api/v1/users/me` with a bearer token.\n",
176+
)
177+
const result = runValidator(skillDir, docPath)
178+
expect(result.code).toBe(0)
179+
expect(result.stdout).not.toContain("FLAG")
180+
})
181+
182+
test("checks an in-repo absolute citation whose relative form starts with ..", () => {
183+
const hiddenDir = path.join(repo, "..hidden")
184+
mkdirSync(hiddenDir, { recursive: true })
185+
writeFileSync(path.join(hiddenDir, "file.ts"), "export const y = 2\n")
186+
const docPath = writeRepoDoc(
187+
"The odd path is `" + path.join(hiddenDir, "file.ts") + "`.\n",
188+
)
189+
const result = runValidator(skillDir, docPath)
190+
expect(result.code).toBe(0)
191+
expect(result.stdout).not.toContain("checked 0 paths")
192+
})
193+
194+
test("flags a missing in-repo absolute citation whose relative form starts with ..", () => {
195+
const docPath = writeRepoDoc(
196+
"The odd path is `" +
197+
path.join(repo, "..hidden", "missing.ts") +
198+
"`.\n",
199+
)
200+
const result = runValidator(skillDir, docPath)
201+
expect(result.code).toBe(1)
202+
expect(result.stdout).toContain("FLAG path")
203+
})
204+
205+
test("checks an absolute citation of a root-level file", () => {
206+
writeFileSync(path.join(repo, "root-cited.ts"), "export const r = 1\n")
207+
const docPath = writeRepoDoc(
208+
"The root helper is `" + path.join(repo, "root-cited.ts") + "`.\n",
209+
)
210+
const result = runValidator(skillDir, docPath)
211+
expect(result.code).toBe(0)
212+
expect(result.stdout).not.toContain("checked 0 paths")
213+
})
214+
215+
test("flags a missing root-level absolute citation", () => {
216+
const docPath = writeRepoDoc(
217+
"The root helper is `" + path.join(repo, "missing-root.ts") + "`.\n",
218+
)
219+
const result = runValidator(skillDir, docPath)
220+
expect(result.code).toBe(1)
221+
expect(result.stdout).toContain("FLAG path")
222+
expect(result.stdout).not.toContain("checked 0 paths")
223+
})
224+
225+
test("slash-normalizes a rewritten Windows relative path", () => {
226+
const driver = String.raw`
227+
import importlib.util, os, sys
228+
spec = importlib.util.spec_from_file_location("v", sys.argv[1])
229+
mod = importlib.util.module_from_spec(spec)
230+
spec.loader.exec_module(mod)
231+
real_relpath = os.path.relpath
232+
os.path.relpath = lambda *a, **k: "src\\real-file.ts"
233+
try:
234+
got = mod.strip_repo_prefix("/repo/src/real-file.ts", "/repo")
235+
finally:
236+
os.path.relpath = real_relpath
237+
print(got)
238+
raise SystemExit(0 if got == "src/real-file.ts" else 1)
239+
`
240+
const result = spawnSync(
241+
"python3",
242+
["-c", driver, scriptPath(skillDir)],
243+
{ encoding: "utf8" },
244+
)
245+
expect(result.status, result.stderr).toBe(0)
246+
expect(result.stdout.trim()).toBe("src/real-file.ts")
247+
})
248+
153249
test("classifies a path that only exists upstream as stale-checkout", () => {
154250
const docPath = writeRepoDoc(
155251
"See `src/upstream-only.ts` for the new helper.\n",

0 commit comments

Comments
 (0)