Skip to content

Commit aa85abc

Browse files
Coding-Dev-ToolsDevForge Engineer
andauthored
fix(sitemap): improve blog coverage and link resolution with regression tests (#70)
* cowork-bot: fix ruff E402 noqa on test imports tests/test_linkcheck_additional.py and tests/test_linkcheck_edge_cases.py perform a sys.path.insert before importing linkcheck, which means the import must come after the path manipulation. Ruff flags this as E402 (module-level import not at top of file). Add '# noqa: E402' to silence the expected warning, matching the existing suppression in test_linkcheck.py. This makes 'ruff check .' pass cleanly and unblocks CI. * cowork-bot: add cowork-auto-pr workflow Seeds the GitHub Actions workflow that opens a PR server-side for this branch (sandbox cannot reach the GitHub API directly). * cowork-bot: fix link resolution for ../ intermediates and absolute paths; update article count * cowork-bot: fix sitemap.xml coverage — remove stale traction-hits URL, add 5 missing blog articles; add regression tests --------- Co-authored-by: DevForge Engineer <engineer@devforge.dev>
1 parent 154c291 commit aa85abc

8 files changed

Lines changed: 166 additions & 34 deletions

File tree

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Seeded by the repo-improver-rotation Cowork job into cowork/improve-* branches.
2+
# Opens a PR automatically when such a branch is pushed (sandbox cannot reach
3+
# the GitHub API directly; this runs server-side with the repo's GITHUB_TOKEN).
4+
name: cowork-auto-pr
5+
on:
6+
push:
7+
branches: ['cowork/improve-**']
8+
permissions:
9+
contents: read
10+
pull-requests: write
11+
jobs:
12+
ensure-pr:
13+
runs-on: ubuntu-latest
14+
steps:
15+
- name: Open PR for this branch if none exists
16+
env:
17+
GH_TOKEN: ${{ github.token }}
18+
run: |
19+
set -eu
20+
existing=$(gh pr list --repo "$GITHUB_REPOSITORY" --head "$GITHUB_REF_NAME" --state open --json number --jq 'length')
21+
if [ "$existing" = "0" ]; then
22+
gh pr create --repo "$GITHUB_REPOSITORY" \
23+
--head "$GITHUB_REF_NAME" \
24+
--title "cowork-bot: automated improvements ($GITHUB_REF_NAME)" \
25+
--body "Automated improvement PR from the Cowork repo-improver rotation (one coherent senior-dev improvement per run; see individual commit messages). Subsequent runs push additional commits to this PR rather than opening new ones."
26+
else
27+
echo "Open PR already exists for $GITHUB_REF_NAME — nothing to do."
28+
fi

‎.hermes/linkcheck.py‎

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -37,17 +37,25 @@ def build_actual_pages(all_files: set[str]) -> set[str]:
3737

3838
def resolve_link(link: str, source_dir: str) -> str:
3939
"""Resolve a relative href to an absolute site-relative path."""
40-
if link.startswith("../"):
41-
parts = source_dir.split("/") if source_dir else []
42-
up_count = link.count("../")
43-
resolved_parts = parts[:-up_count] if len(parts) >= up_count else []
44-
remaining = link[3 * up_count :]
45-
resolved = "/".join(resolved_parts + [remaining]) if resolved_parts else remaining
46-
elif link.startswith("./"):
47-
resolved = (source_dir + "/" + link[2:]) if source_dir else link[2:]
40+
if link.startswith("http://") or link.startswith("https://") or link.startswith("#"):
41+
return link
42+
if link.startswith("/"):
43+
return link.lstrip("/")
44+
45+
if source_dir:
46+
path = source_dir + "/" + link
4847
else:
49-
resolved = (source_dir + "/" + link) if source_dir else link
50-
return resolved.replace("//", "/")
48+
path = link
49+
50+
normalized = os.path.normpath(path).replace(os.sep, "/")
51+
52+
# Cap at site root: strip any leading ../ that goes above root
53+
while normalized.startswith("../"):
54+
normalized = normalized[3:]
55+
if normalized.startswith("/"):
56+
normalized = normalized[1:]
57+
58+
return normalized
5159

5260

5361
def check_links(root_dir: str, verbose: bool = False) -> int:

‎README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
[![Deploy](https://img.shields.io/badge/deploy-GitHub%20Pages-brightgreen)](https://coding-dev-tools.github.io/devforge/)
44
[![Stars](https://img.shields.io/github/stars/Coding-Dev-Tools/devforge?style=social)](https://github.com/Coding-Dev-Tools/devforge/stargazers)
55

6-
Landing page, documentation, pricing, alternatives, and blog for the **DevForge CLI tool suite** — 11 developer CLI tools for API contracts, SQL, infrastructure, config drift, and more. **60 articles and growing.**
6+
Landing page, documentation, pricing, alternatives, and blog for the **DevForge CLI tool suite** — 11 developer CLI tools for API contracts, SQL, infrastructure, config drift, and more. **70 articles and growing.**
77

88
**Live site:** [coding-dev-tools.github.io/devforge/](https://coding-dev-tools.github.io/devforge/)
99

@@ -14,7 +14,7 @@ Landing page, documentation, pricing, alternatives, and blog for the **DevForge
1414
| [Home](https://coding-dev-tools.github.io/devforge/) | Hero, stats, feature cards, CTA |
1515
| [Pricing](https://coding-dev-tools.github.io/devforge/pricing.html) | Free / Pro / Team / Enterprise tiers |
1616
| [Alternatives](https://coding-dev-tools.github.io/devforge/alternatives.html) | 11 comparison tables vs competitors |
17-
| [Blog](https://coding-dev-tools.github.io/devforge/blog.html) | 60+ articles and tutorials |
17+
| [Blog](https://coding-dev-tools.github.io/devforge/blog.html) | 70+ articles and tutorials |
1818
| [Docs](https://coding-dev-tools.github.io/devforge/docs.html) | Tool documentation hub |
1919
| [Quickstart](https://coding-dev-tools.github.io/devforge/quickstart.html) | Get started in 60 seconds |
2020
| [FAQ](https://coding-dev-tools.github.io/devforge/#faq) | Common questions |

‎sitemap.xml‎

Lines changed: 47 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,6 @@
3333
<url>
3434
<loc>https://coding-dev-tools.github.io/devforge/quickstart.html</loc>
3535
<lastmod>2026-05-17</lastmod>
36-
37-
<loc>https://coding-dev-tools.github.io/revenueholdings.dev/blog/openapi-diff-tools-comparison.html</loc>
38-
<lastmod>2026-05-15</lastmod>
3936
<changefreq>monthly</changefreq>
4037
<priority>0.9</priority>
4138
</url>
@@ -447,28 +444,58 @@
447444
<changefreq>monthly</changefreq>
448445
<priority>0.6</priority>
449446
</url>
450-
<url>
451-
<loc>https://coding-dev-tools.github.io/devforge/blog/traction-hits-680-developers.html</loc>
452-
<lastmod>2026-05-17</lastmod>
453-
<changefreq>monthly</changefreq>
454-
<priority>0.6</priority>
455-
</url>
456-
457-
<loc>https://coding-dev-tools.github.io/revenueholdings.dev/alternatives.html</loc>
458-
<lastmod>2026-05-15</lastmod>
447+
<url>
448+
<loc>https://coding-dev-tools.github.io/devforge/blog/click-to-mcp-three-distribution-channels.html</loc>
449+
<lastmod>2026-06-13</lastmod>
459450
<changefreq>monthly</changefreq>
460-
<priority>0.9</priority>
451+
<priority>0.6</priority>
452+
</url>
453+
<url>
454+
<loc>https://coding-dev-tools.github.io/devforge/blog/click-to-mcp-three-transport-modes.html</loc>
455+
<lastmod>2026-05-28</lastmod>
456+
<changefreq>monthly</changefreq>
457+
<priority>0.6</priority>
461458
</url>
459+
<url>
460+
<loc>https://coding-dev-tools.github.io/devforge/blog/configdrift-scan-multi-environment-configs-one-command.html</loc>
461+
<lastmod>2026-05-28</lastmod>
462+
<changefreq>monthly</changefreq>
463+
<priority>0.6</priority>
464+
</url>
465+
<url>
466+
<loc>https://coding-dev-tools.github.io/devforge/blog/deploydiff-cost-governance-before-you-deploy.html</loc>
467+
<lastmod>2026-05-28</lastmod>
468+
<changefreq>monthly</changefreq>
469+
<priority>0.6</priority>
470+
</url>
471+
<url>
472+
<loc>https://coding-dev-tools.github.io/devforge/blog/saas-churn-predictor-launch.html</loc>
473+
<lastmod>2026-05-28</lastmod>
474+
<changefreq>monthly</changefreq>
475+
<priority>0.6</priority>
476+
</url>
477+
<url>
478+
<loc>https://coding-dev-tools.github.io/devforge/blog/apicontractguardian-git-branch-openapi-diff-pr-review.html</loc>
479+
<lastmod>2026-08-26</lastmod>
480+
<changefreq>weekly</changefreq>
481+
<priority>0.6</priority>
482+
</url>
462483
<url>
463-
<loc>https://coding-dev-tools.github.io/revenueholdings.dev/quickstart.html</loc>
464-
<lastmod>2026-05-15</lastmod>
465-
<changefreq>monthly</changefreq>
466-
<priority>0.9</priority>
484+
<loc>https://coding-dev-tools.github.io/devforge/blog/deadcode-remove-dead-code-nextjs-app-router.html</loc>
485+
<lastmod>2026-08-26</lastmod>
486+
<changefreq>weekly</changefreq>
487+
<priority>0.6</priority>
488+
</url>
489+
<url>
490+
<loc>https://coding-dev-tools.github.io/devforge/blog/json2sql-cicd-automated-database-seeding.html</loc>
491+
<lastmod>2026-08-26</lastmod>
492+
<changefreq>weekly</changefreq>
493+
<priority>0.6</priority>
467494
</url>
468495
<url>
469-
<loc>https://coding-dev-tools.github.io/revenueholdings.dev/blog/clean-up-react-dead-code.html</loc>
470-
<lastmod>2026-05-15</lastmod>
471-
<changefreq>monthly</changefreq>
496+
<loc>https://coding-dev-tools.github.io/devforge/blog/schemaforge-convert-prisma-to-drizzle-migration-guide.html</loc>
497+
<lastmod>2026-08-26</lastmod>
498+
<changefreq>weekly</changefreq>
472499
<priority>0.6</priority>
473500
</url>
474501
</urlset>

‎tests/test_linkcheck.py‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,24 @@ def test_resolve_link_double_slash():
167167
assert result == "blog/page.html"
168168

169169

170+
def test_resolve_link_absolute_path():
171+
"""Absolute path /about.html strips leading slash."""
172+
result = resolve_link("/about.html", "blog")
173+
assert result == "about.html"
174+
175+
176+
def test_resolve_link_up_with_intermediate_segments():
177+
"""../foo/../bar.html correctly cancels the intermediate segment."""
178+
result = resolve_link("../foo/../bar.html", "blog/2024")
179+
assert result == "blog/bar.html"
180+
181+
182+
def test_resolve_link_up_capped_at_root():
183+
"""../../ from a shallow dir is capped at site root."""
184+
result = resolve_link("../../page.html", "blog")
185+
assert result == "page.html"
186+
187+
170188
# ---- CLI argument parsing ----
171189

172190

‎tests/test_linkcheck_additional.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
_SCRIPT_DIR = Path(__file__).resolve().parent.parent / ".hermes"
1212
sys.path.insert(0, str(_SCRIPT_DIR))
1313

14-
from linkcheck import build_actual_pages
14+
from linkcheck import build_actual_pages # noqa: E402
1515

1616

1717
class TestBuildActualPages:

‎tests/test_linkcheck_edge_cases.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
_SCRIPT_DIR = Path(__file__).resolve().parent.parent / ".hermes"
1717
sys.path.insert(0, str(_SCRIPT_DIR))
1818

19-
from linkcheck import check_links, main
19+
from linkcheck import check_links, main # noqa: E402
2020

2121

2222
class TestCheckLinksEdgeCases:

‎tests/test_sitemap.py‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"""Regression tests: sitemap.xml must exactly cover the site's real pages.
2+
3+
Every non-404 HTML file in the repo must have a <loc> entry, and every
4+
<loc> must resolve to a real page (index.html is represented as "/").
5+
"""
6+
7+
import os
8+
import re
9+
import xml.etree.ElementTree as ET
10+
from pathlib import Path
11+
12+
REPO = Path(__file__).resolve().parent.parent
13+
SITEMAP = REPO / "sitemap.xml"
14+
BASE = "https://coding-dev-tools.github.io/devforge/"
15+
NS = "{http://www.sitemaps.org/schemas/sitemap/0.9}"
16+
17+
18+
def _actual_pages():
19+
pages = set()
20+
for root, dirs, files in os.walk(REPO):
21+
rel_root = Path(root).relative_to(REPO)
22+
dirs[:] = [d for d in dirs if d not in (".git", "drafts", ".hermes", "tests")]
23+
for fn in files:
24+
if fn.endswith(".html") and fn != "404.html":
25+
p = str(rel_root / fn).replace(os.sep, "/")
26+
pages.add(BASE if p == "index.html" else BASE + p)
27+
return pages
28+
29+
30+
def _sitemap_urls():
31+
tree = ET.parse(SITEMAP)
32+
return {e.text for e in tree.getroot().iter(NS + "loc")}
33+
34+
35+
def test_sitemap_parses():
36+
ET.parse(SITEMAP)
37+
38+
39+
def test_sitemap_covers_every_page():
40+
missing = _actual_pages() - _sitemap_urls()
41+
assert not missing, f"pages missing from sitemap.xml: {sorted(missing)}"
42+
43+
44+
def test_sitemap_has_no_stale_urls():
45+
stale = _sitemap_urls() - _actual_pages()
46+
assert not stale, f"stale sitemap.xml URLs with no page: {sorted(stale)}"
47+
48+
49+
def test_no_duplicate_locs():
50+
urls = re.findall(r"<loc>(.*?)</loc>", SITEMAP.read_text(encoding="utf-8"))
51+
assert len(urls) == len(set(urls))

0 commit comments

Comments
 (0)