diff --git a/.github/workflows/seo.yml b/.github/workflows/seo.yml index 12f7bde..b4039af 100644 --- a/.github/workflows/seo.yml +++ b/.github/workflows/seo.yml @@ -93,5 +93,11 @@ jobs: --scheme http --scheme https --exclude '^https://docs\.flyto2\.com' + --exclude '^https://github\.com/flytohub/flyto-docs/blob/main/' + --exclude '^https://github\.com/flytohub/flyto-flow' + --exclude '^https://github\.com/flytohub/flyto-warroom' --accept 200,204,206,301,302,307,308,429 './.external-links.txt' + + - name: Check that link exclusions have not outlived their reason + run: npm run check:unpublished diff --git a/config/unpublished-repos.json b/config/unpublished-repos.json new file mode 100644 index 0000000..edbb227 --- /dev/null +++ b/config/unpublished-repos.json @@ -0,0 +1,26 @@ +{ + "comment": [ + "Repositories the documentation already describes and links to, that are not", + "public yet. Their links 404 for a reader today, which is why the external", + "link check excludes them.", + "", + "The exclusion is meant to expire. check-unpublished-links.mjs fails once a", + "repository here answers publicly, because at that moment the exclusion is", + "hiding a link that works and the docs should be checked against it again.", + "", + "Removing a repository from this list means removing its --exclude from the", + "lychee step in .github/workflows/seo.yml at the same time." + ], + "repositories": [ + { + "owner": "flytohub", + "name": "flyto-flow", + "reason": "Flow CE source-available publication is prepared but paused" + }, + { + "owner": "flytohub", + "name": "flyto-warroom", + "reason": "Warroom CE publication is prepared but paused" + } + ] +} diff --git a/docs/documentation-manifest.json b/docs/documentation-manifest.json index 3135e55..efb3369 100644 --- a/docs/documentation-manifest.json +++ b/docs/documentation-manifest.json @@ -4,7 +4,12 @@ "version": 1, "repository": "flyto-docs", "status": "active", - "audiences": ["Flyto2 users", "operators", "integrators", "documentation contributors"], + "audiences": [ + "Flyto2 users", + "operators", + "integrators", + "documentation contributors" + ], "documentation": { "index": "docs/README.md", "architecture": "ARCHITECTURE.md", @@ -15,38 +20,86 @@ "feature_reference": "docs/README.md", "cloud_architecture": "cloud/mission-stations.md", "source_reference": "reference/docs-code.md", - "module_roots": [".vitepress", "scripts"] + "module_roots": [ + ".vitepress", + "scripts" + ] }, "owners": [ { "area": "vitepress-runtime", - "paths": [".vitepress/**"], - "docs": ["ARCHITECTURE.md", "reference/docs-code.md"] + "paths": [ + ".vitepress/**" + ], + "docs": [ + "ARCHITECTURE.md", + "reference/docs-code.md" + ] }, { "area": "documentation-generators", - "paths": ["scripts/**"], - "docs": ["scripts/README.md", "reference/docs-code.md"] + "paths": [ + "scripts/**", + "config/**" + ], + "docs": [ + "scripts/README.md", + "reference/docs-code.md" + ] }, { "area": "package-and-build", - "paths": ["package.json", "package-lock.json", "lighthouserc.cjs"], - "docs": ["README.md", "docs/README.md"] + "paths": [ + "package.json", + "package-lock.json", + "lighthouserc.cjs" + ], + "docs": [ + "README.md", + "docs/README.md" + ] }, { "area": "automation", - "paths": [".github/**"], - "docs": ["engineering/ci-standard.md", "docs/README.md"] + "paths": [ + ".github/**" + ], + "docs": [ + "engineering/ci-standard.md", + "docs/README.md" + ] }, { "area": "seo-contract", - "paths": [".seo/**", "public/*.txt", "public/*.xml", "public/*.json", "public/**/*.txt", "public/robots.txt", "public/CNAME"], - "docs": ["scripts/README.md", "STATE.md"] + "paths": [ + ".seo/**", + "public/*.txt", + "public/*.xml", + "public/*.json", + "public/**/*.txt", + "public/robots.txt", + "public/CNAME" + ], + "docs": [ + "scripts/README.md", + "STATE.md" + ] }, { "area": "repository-policy", - "paths": [".flyto-rules.yaml", ".flyto/coding.yaml", ".gitleaks.toml", ".gitignore", ".env.example", "docs/documentation-manifest.json"], - "docs": ["AGENTS.md", "SECURITY.md", "docs/README.md"] + "paths": [ + ".flyto-rules.yaml", + ".flyto/coding.yaml", + ".gitleaks.toml", + ".gitignore", + ".env.example", + "docs/documentation-manifest.json" + ], + "docs": [ + "AGENTS.md", + "SECURITY.md", + "docs/README.md" + ] } ] } diff --git a/package.json b/package.json index 28c67a1..dc8aa70 100644 --- a/package.json +++ b/package.json @@ -16,10 +16,11 @@ "links:external": "node scripts/list-external-links.mjs", "seo:discovery": "node scripts/generate-discovery.mjs", "build": "npm run seo:discovery && vitepress build", - "verify": "npm run test && npm run lint && npm run build && npm run check:links && npm run audit:seo && npm run seo:score && npm run seo:manage", + "verify": "npm run test && npm run lint && npm run build && npm run check:links && npm run check:unpublished && npm run audit:seo && npm run seo:score && npm run seo:manage", "docs:dev": "vitepress dev", "docs:build": "npm run build", - "docs:preview": "vitepress preview" + "docs:preview": "vitepress preview", + "check:unpublished": "node scripts/check-unpublished-links.mjs" }, "devDependencies": { "vitepress": "^1.6.4" diff --git a/reference/docs-code.md b/reference/docs-code.md index c6be8a0..c2871e6 100644 --- a/reference/docs-code.md +++ b/reference/docs-code.md @@ -4,7 +4,7 @@ Source map for every maintained function and configuration method in the Flyto2 Docs generators, audits, SEO gates, and VitePress configuration. -Inventory: **14 files**, **3915 lines**, and **156 declarations**. +Inventory: **15 files**, **4029 lines**, and **158 declarations**. ## `.vitepress/config.mts` @@ -95,6 +95,12 @@ Inventory: **14 files**, **3915 lines**, and **156 declarations**. | function | `checkUrl(rawUrl, fromFile)` | Implements `checkUrl` in this documentation surface. | [`scripts/check-public-links.mjs:92`](https://github.com/flytohub/flyto-docs/blob/main/scripts/check-public-links.mjs#L92) | | function | `checkHtmlFile(filePath)` | Implements `checkHtmlFile` in this documentation surface. | [`scripts/check-public-links.mjs:108`](https://github.com/flytohub/flyto-docs/blob/main/scripts/check-public-links.mjs#L108) | +## `scripts/check-unpublished-links.mjs` + +| Kind | Signature | Responsibility | Source | +|---|---|---|---| +| function | `isPublic(owner, name)` | Implements `isPublic` in this documentation surface. | [`scripts/check-unpublished-links.mjs:34`](https://github.com/flytohub/flyto-docs/blob/main/scripts/check-unpublished-links.mjs#L34) | + ## `scripts/generate-code-reference.py` | Kind | Signature | Responsibility | Source | @@ -105,7 +111,8 @@ Inventory: **14 files**, **3915 lines**, and **156 declarations**. | function | `def javascript_declarations(path: Path)` | Implements `javascript_declarations`. | [`scripts/generate-code-reference.py:58`](https://github.com/flytohub/flyto-docs/blob/main/scripts/generate-code-reference.py#L58) | | function | `def escape(value: str)` | Implements `escape`. | [`scripts/generate-code-reference.py:79`](https://github.com/flytohub/flyto-docs/blob/main/scripts/generate-code-reference.py#L79) | | function | `def render()` | Implements `render`. | [`scripts/generate-code-reference.py:83`](https://github.com/flytohub/flyto-docs/blob/main/scripts/generate-code-reference.py#L83) | -| function | `def main()` | Implements `main`. | [`scripts/generate-code-reference.py:126`](https://github.com/flytohub/flyto-docs/blob/main/scripts/generate-code-reference.py#L126) | +| function | `def unresolved_source_links(content: str)` | Self-links that name a file or line this working tree does not have. | [`scripts/generate-code-reference.py:126`](https://github.com/flytohub/flyto-docs/blob/main/scripts/generate-code-reference.py#L126) | +| function | `def main()` | Implements `main`. | [`scripts/generate-code-reference.py:151`](https://github.com/flytohub/flyto-docs/blob/main/scripts/generate-code-reference.py#L151) | ## `scripts/generate-discovery.mjs` diff --git a/scripts/README.md b/scripts/README.md index 149d652..02d1d18 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -27,6 +27,19 @@ Important checks: inventories, Core provenance, declaration totals, code-reference drift, source ownership, Flyto2 naming, and the 16 approved public mailboxes. - `check-public-links.mjs` resolves every built internal link and anchor. +- `check-unpublished-links.mjs` retires the external-link exclusions. The + docs describe Flow CE and Warroom CE before those repositories are public, + so their links 404 today and the lychee step in `seo.yml` excludes them. + This reads `config/unpublished-repos.json` and fails once a listed + repository answers publicly, which is the moment the exclusion starts + hiding a link that works. Removing an entry there means removing its + matching `--exclude` in the same change. +- The lychee step also excludes this repository's own + `blob/main/...` source links. A file added by a pull request does not + exist on `main` until it merges, so those links 404 for the run that + introduces them and no external check can judge them. They are checked + instead by `generate-code-reference.py --check`, against the working + tree that produced them, which is the only place the answer is knowable. - `seo-score.mjs` and `seo-manage.mjs` create page-level and portfolio-level reports from built evidence rather than subjective claims. diff --git a/scripts/check-unpublished-links.mjs b/scripts/check-unpublished-links.mjs new file mode 100644 index 0000000..1178ecf --- /dev/null +++ b/scripts/check-unpublished-links.mjs @@ -0,0 +1,84 @@ +/** + * Expire the external-link exclusions once the repositories behind them ship. + * + * The docs describe Flow CE and Warroom CE ahead of their publication, so those + * repository links 404 for a reader today and the lychee step excludes them. An + * exclusion that outlives its reason is worse than the 404 it hid: the day the + * repositories go public, nothing checks those links again and nobody + * remembers the flag is there. + * + * So this fails in exactly that case. While a repository is still private it + * says so and passes; the moment it answers publicly it fails and asks for the + * exclusion to be removed. + */ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const configPath = path.join(root, 'config', 'unpublished-repos.json'); +const workflowPath = path.join(root, '.github', 'workflows', 'seo.yml'); + +const config = JSON.parse(readFileSync(configPath, 'utf8')); +const repositories = config.repositories ?? []; +const workflow = readFileSync(workflowPath, 'utf8'); + +const failures = []; +const notes = []; + +// GitHub's own shape for an owner or repository name. Interpolating anything +// else would let an edit to the config file choose where this script sends a +// request -- a path traversal, or a whole other host smuggled through a name. +const SLUG = /^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,98}[A-Za-z0-9])?$/; + +async function isPublic(owner, name) { + if (!SLUG.test(owner) || !SLUG.test(name)) { + throw new Error(`not a repository slug: ${JSON.stringify(`${owner}/${name}`)}`); + } + const url = new URL(`https://api.github.com/repos/${owner}/${name}`); + if (url.origin !== 'https://api.github.com' || !url.pathname.startsWith('/repos/')) { + throw new Error(`refusing to request ${url.origin}${url.pathname}`); + } + const response = await fetch(url, { + headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'flyto-docs-link-audit' }, + redirect: 'error', + }); + if (response.status === 404) return false; + if (response.status === 200) return true; + // Rate limiting or an outage must not turn into a false "now public", and + // must not silently pass either. + throw new Error(`GitHub answered ${response.status} for ${owner}/${name}`); +} + +for (const entry of repositories) { + const { owner, name } = entry; + const slug = `${owner}/${name}`; + if (!workflow.includes(name)) { + failures.push(`${slug} is listed as unpublished but no lychee --exclude mentions it`); + continue; + } + let published; + try { + published = await isPublic(owner, name); + } catch (error) { + failures.push(`could not determine visibility of ${slug}: ${error.message}`); + continue; + } + if (published) { + failures.push( + `${slug} is public now. Remove its --exclude from .github/workflows/seo.yml and its entry ` + + `from config/unpublished-repos.json so the docs links to it are checked again.`, + ); + } else { + notes.push(`${slug} is still private (${entry.reason ?? 'no reason recorded'})`); + } +} + +if (failures.length > 0) { + console.error('unpublished link exclusions need attention:'); + for (const failure of failures) console.error(`- ${failure}`); + process.exit(1); +} + +console.log(`unpublished link exclusions still apply: ${repositories.length} repository(ies)`); +for (const note of notes) console.log(` ${note}`); diff --git a/scripts/generate-code-reference.py b/scripts/generate-code-reference.py index 8e1437c..84cddf6 100644 --- a/scripts/generate-code-reference.py +++ b/scripts/generate-code-reference.py @@ -123,6 +123,31 @@ def render() -> str: ) +def unresolved_source_links(content: str) -> list[str]: + """Self-links that name a file or line this working tree does not have. + + These point at `blob/main`, so a file added by a pull request 404s until it + merges and the external link check cannot judge them. It is skipped for + that reason -- which leaves this as the only place they are checked at all, + against the tree that produced them. + """ + prefix = "https://github.com/flytohub/flyto-docs/blob/main/" + failures = [] + for match in re.finditer(re.escape(prefix) + r"([^)#\s]+)#L(\d+)", content): + relative, line = match.group(1), int(match.group(2)) + path = ROOT / relative + if not path.is_file(): + failures.append(f"source link names a missing file: {relative}") + continue + try: + total = len(path.read_text(encoding="utf-8").splitlines()) + except (OSError, UnicodeDecodeError): + continue + if line > total: + failures.append(f"source link names line {line} of {relative}, which has {total}") + return failures + + def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--check", action="store_true") @@ -132,6 +157,11 @@ def main() -> int: if not OUTPUT.exists() or OUTPUT.read_text(encoding="utf-8") != content: print(f"generated reference drift: {OUTPUT.relative_to(ROOT)}", file=sys.stderr) return 1 + broken = unresolved_source_links(OUTPUT.read_text(encoding="utf-8")) + if broken: + for failure in broken: + print(failure, file=sys.stderr) + return 1 print(f"docs code reference checked: {OUTPUT.relative_to(ROOT)}") return 0 OUTPUT.parent.mkdir(parents=True, exist_ok=True)