From a05b11b26befb46185e21efdb608f7b97856fef2 Mon Sep 17 00:00:00 2001 From: hanthor Date: Thu, 13 Aug 2026 19:32:46 +0000 Subject: [PATCH] fix(sync): skip archived repos and guard doc collisions --- .github/workflows/lint.yml | 3 ++ scripts/__tests__/sync-org-docs.test.mjs | 18 ++++++++++ scripts/check-doc-collisions.mjs | 45 ++++++++++++++++++++++++ scripts/sync-org-docs.mjs | 22 +++++++----- 4 files changed, 79 insertions(+), 9 deletions(-) create mode 100644 scripts/check-doc-collisions.mjs diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 219d5162..674f874f 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -84,6 +84,9 @@ jobs: done exit $FAILED + - name: Check case-colliding documentation paths + run: node scripts/check-doc-collisions.mjs + - name: Run unit tests run: | echo "=== Running unit tests ===" diff --git a/scripts/__tests__/sync-org-docs.test.mjs b/scripts/__tests__/sync-org-docs.test.mjs index e6e79a5d..e5907753 100644 --- a/scripts/__tests__/sync-org-docs.test.mjs +++ b/scripts/__tests__/sync-org-docs.test.mjs @@ -36,6 +36,7 @@ import { PER_PAGE, DEFAULT_PAGE_SIZE, } from '../sync-org-docs.mjs'; +import {findCaseCollisions} from '../check-doc-collisions.mjs'; // ── Test helpers ────────────────────────────────────────────────────────────── let pass = 0; @@ -531,6 +532,23 @@ test('de-duplicates names repeated across a page boundary', () => { assert.deepEqual(names, ['a', 'b', 'c']); }); +// ── case-collision guard ───────────────────────────────────────────────────── + +console.log('\ncase-collision guard'); + +test('finds paths that differ only by case', () => { + const collisions = findCaseCollisions([ + 'docs/tacklebox/USER-GUIDE.md', + 'docs/tacklebox/user-guide.md', + 'docs/tacklebox/index.md', + ], 'docs'); + assert.deepEqual(collisions, [['tacklebox/USER-GUIDE.md', 'tacklebox/user-guide.md']]); +}); + +test('allows distinct documentation paths', () => { + assert.deepEqual(findCaseCollisions(['docs/a.md', 'docs/b.md'], 'docs'), []); +}); + test('an unpaginated gh yields only page 1 — the regression this guards', () => { // Same stub with paginate off: proves the assertion above is load-bearing // and that a listing capped at a page still looks perfectly well-formed. diff --git a/scripts/check-doc-collisions.mjs b/scripts/check-doc-collisions.mjs new file mode 100644 index 00000000..af951a35 --- /dev/null +++ b/scripts/check-doc-collisions.mjs @@ -0,0 +1,45 @@ +#!/usr/bin/env node + +import {readdirSync} from 'node:fs'; +import {join, relative} from 'node:path'; +import {pathToFileURL} from 'node:url'; + +const DOCS_DIR = 'docs'; + +function filesUnder(root) { + const files = []; + for (const entry of readdirSync(root, {withFileTypes: true})) { + const path = join(root, entry.name); + if (entry.isDirectory()) files.push(...filesUnder(path)); + else if (entry.isFile()) files.push(path); + } + return files; +} + +function findCaseCollisions(paths, root = '.') { + const byLowerPath = new Map(); + for (const path of paths) { + const relativePath = relative(root, path); + const key = relativePath.toLowerCase(); + const matches = byLowerPath.get(key) || []; + matches.push(relativePath); + byLowerPath.set(key, matches); + } + return [...byLowerPath.values()].filter((matches) => matches.length > 1); +} + +function main() { + const collisions = findCaseCollisions(filesUnder(DOCS_DIR), DOCS_DIR); + if (collisions.length === 0) { + console.log(`No case-colliding files found under ${DOCS_DIR}/`); + return; + } + for (const matches of collisions) { + console.error(`Case-colliding docs paths: ${matches.join(' and ')}`); + } + process.exitCode = 1; +} + +export {findCaseCollisions, filesUnder}; + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main(); diff --git a/scripts/sync-org-docs.mjs b/scripts/sync-org-docs.mjs index 8621e4dc..3e539efd 100644 --- a/scripts/sync-org-docs.mjs +++ b/scripts/sync-org-docs.mjs @@ -323,7 +323,7 @@ function getStatusBanner(status) { return banners[status] || null; } -// listOrgRepos returns every repo name in the org, across every page. +// listOrgRepos returns every active repo name in the org, across every page. // // The call this replaced was `gh api orgs//repos --jq '.[].name'` with no // --paginate, so GitHub answered with the default first page — 30 names — and @@ -342,24 +342,28 @@ function getStatusBanner(status) { // the names are de-duplicated rather than trusted to be unique. function listOrgRepos(exec = execSync) { const out = exec( - `gh api "orgs/${ORG}/repos?per_page=${PER_PAGE}" --paginate --jq '.[].name'`, + `gh api "orgs/${ORG}/repos?per_page=${PER_PAGE}" --paginate --jq '.[] | select(.archived == false) | .name'`, {encoding: 'utf8'}, ); return [...new Set(String(out).split('\n').map((n) => n.trim()).filter(Boolean))]; } -// orgPublicRepoCount reads how many public repos the org says it has. +// orgPublicRepoCount reads how many active public repos the org says it has. // // This is a second, independent source of truth for "how long should the -// listing be". Any token can see every public repo of a public org, so a -// listing shorter than this number was cut short — that is a fact about the -// listing, not a heuristic. Returns null when the count cannot be read, so a -// missing cross-check degrades to the weaker signals below instead of failing -// the sync. +// listing be". Any token can see every active public repo of a public org, so +// a listing shorter than this number was cut short — that is a fact about the +// listing, not a heuristic. --slurp is required because --paginate otherwise +// runs jq once per page and would produce one count per page. Returns null +// when the count cannot be read, so a missing cross-check degrades to the +// weaker signals below instead of failing the sync. function orgPublicRepoCount(exec = execSync) { try { const n = Number( - String(exec(`gh api orgs/${ORG} --jq '.public_repos'`, {encoding: 'utf8'})).trim(), + String(exec( + `gh api "orgs/${ORG}/repos?per_page=${PER_PAGE}" --paginate --slurp --jq 'map(.[] | select(.archived == false)) | length'`, + {encoding: 'utf8'}, + )).trim(), ); return Number.isFinite(n) && n > 0 ? n : null; } catch {