diff --git a/CHANGELOG.md b/CHANGELOG.md index 196cf9d..dcebe80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,12 +4,6 @@ All notable changes to RepoReady are documented here. The project follows [Seman ## [Unreleased] -### Changed - -- Moved the live application to the nested portfolio route at `https://aminhanifm.github.io/projects/repo-ready/`. -- Kept the legacy repository Pages URL as a redirect. -- Replaced em dashes throughout project copy and generated Markdown reports. - ## [1.0.0] - 2026-07-20 ### Added @@ -24,6 +18,20 @@ All notable changes to RepoReady are documented here. The project follows [Seman - Unit, integration, and Playwright browser test suites with no real GitHub requests in CI. - GitHub Actions workflows for continuous integration and Pages deployment. - Open-source governance and community health files. +- Complete Open Graph, Twitter card, JSON-LD, favicon, and social-preview metadata. +- End-to-end usage guidance for analyzing and exporting a repository report. + +### Fixed + +- Replaced repository-size-only empty detection with positive README and tree evidence, preventing populated size-zero repositories from producing false empty reports. +- Invalidated previously cached reports created by the incorrect empty-repository logic. +- Updated the bundled CSV Health fixture to its canonical nested live URL. + +### Changed + +- Moved the live application to `https://aminhanifm.github.io/projects/repo-ready/`. +- Kept the legacy repository Pages URL as a redirect. +- Replaced em dashes throughout project copy and generated Markdown reports. [Unreleased]: https://github.com/aminhanifm/RepoReady/compare/v1.0.0...HEAD [1.0.0]: https://github.com/aminhanifm/RepoReady/releases/tag/v1.0.0 diff --git a/README.md b/README.md index 33c92f2..0f5c11e 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,14 @@ npm run dev Open the local address printed by Vite. No environment variables or GitHub credentials are required. +## Usage + +1. Open the [live RepoReady application](https://aminhanifm.github.io/projects/repo-ready/). +2. Enter a public repository as `owner/repository` or paste its complete GitHub URL. +3. Select **Analyze** to collect the repository's public evidence. +4. Review the readiness score, scan coverage, category results, evidence, and recommendations. +5. Export the complete report as Markdown or JSON when you need a stable record. + ## Architecture RepoReady is a static React application with a deliberately one-way data flow: @@ -84,7 +92,7 @@ Every rule, point value, evidence heuristic, limitation, and formula is specifie ## GitHub API and rate limits -RepoReady uses unauthenticated requests to the public GitHub REST API. A normal non-empty scan makes up to six requests: repository metadata, community profile, README, recursive tree, latest public release, and latest default-branch check runs. +RepoReady uses unauthenticated requests to the public GitHub REST API. A scan makes up to six requests: repository metadata, community profile, README, recursive tree, latest public release, and latest default-branch check runs. These evidence requests are attempted even when GitHub reports a repository size of zero because size alone does not prove that a repository is empty. The interface displays GitHub's observed request limit, remaining allowance, and reset time. A `404` is reported as “repository not found or not publicly accessible.” Rate limits, malformed responses, network failures, server errors, missing endpoints, and truncated trees are surfaced explicitly. Affected checks remain unknown when the repository itself was successfully identified. diff --git a/SCORING.md b/SCORING.md index 4df5d8e..fe10836 100644 --- a/SCORING.md +++ b/SCORING.md @@ -89,7 +89,7 @@ Recommendations come only from failed checks, are ordered by points descending, ## Repository conditions and limitations -Archived, forked, and empty repositories are analyzed but clearly flagged. These conditions do not change points because they describe repository context rather than readiness evidence. Empty repositories typically have many observable failures; missing or blocked endpoints remain unknown. +Archived, forked, and empty repositories are analyzed but clearly flagged. These conditions do not change points because they describe repository context rather than readiness evidence. GitHub's repository size is not authoritative evidence of emptiness. RepoReady marks a repository empty only when size is zero, the README is observably absent, and the tree is observably empty or GitHub explicitly reports that no default-branch commit exists. Inconclusive requests remain unknown and never create an empty classification. Unauthenticated GitHub REST API limits apply. RepoReady displays the latest observed `X-RateLimit-Remaining`, `X-RateLimit-Limit`, and `X-RateLimit-Reset` values. When the limit is exhausted, no speculative retry is performed. Completed reports are cached in `sessionStorage` for about ten minutes using normalized repository name plus ruleset version. Starting a different analysis aborts the previous request. diff --git a/index.html b/index.html index e8328c3..5aba992 100644 --- a/index.html +++ b/index.html @@ -4,17 +4,54 @@ - + + + + + + + + + + + + + RepoReady | Know what your repository communicates diff --git a/package.json b/package.json index 8daf710..f462f9f 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ "test": "vitest run", "test:watch": "vitest", "test:e2e": "node scripts/run-e2e.mjs", - "screenshot": "node scripts/capture-screenshot.mjs" + "screenshot": "node scripts/capture-screenshot.mjs", + "social-image": "node scripts/capture-social-image.mjs" }, "dependencies": { "lucide-react": "^0.468.0", diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..b95d7d3 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,19 @@ + + RepoReady + + + + + + + + + + diff --git a/public/repo-ready-social.png b/public/repo-ready-social.png new file mode 100644 index 0000000..95aa627 Binary files /dev/null and b/public/repo-ready-social.png differ diff --git a/scripts/capture-social-image.mjs b/scripts/capture-social-image.mjs new file mode 100644 index 0000000..19976cd --- /dev/null +++ b/scripts/capture-social-image.mjs @@ -0,0 +1,134 @@ +import { mkdir } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { chromium } from "playwright"; + +const projectRoot = dirname(dirname(fileURLToPath(import.meta.url))); +const outputPath = join(projectRoot, "public", "repo-ready-social.png"); + +const browser = await chromium.launch( + process.platform === "win32" && !process.env.CI ? { channel: "chrome" } : {}, +); + +try { + const page = await browser.newPage({ viewport: { width: 1200, height: 630 } }); + await page.setContent(` + + + + + + +
+
+ + + + RepoReady +
+
Evidence, not opinion
+

Know what your repository communicates.

+

Deterministic readiness reports for public GitHub repositories.

+
+
+
+
84
+
READINESS

Ready with minor improvements

92% scan coverage
+
+
Documentation88
+
Engineering76
+
Community82
+
EXPLAINABLE / ACTIONABLE
+
+ + `); + await mkdir(dirname(outputPath), { recursive: true }); + await page.screenshot({ path: outputPath, animations: "disabled" }); + console.log(outputPath); +} finally { + await browser.close(); +} diff --git a/src/api/collectRepositoryFacts.integration.test.ts b/src/api/collectRepositoryFacts.integration.test.ts index e3e1640..f42ac3e 100644 --- a/src/api/collectRepositoryFacts.integration.test.ts +++ b/src/api/collectRepositoryFacts.integration.test.ts @@ -14,11 +14,17 @@ interface MockOptions { archived?: boolean; fork?: boolean; empty?: boolean; + sizeZero?: boolean; incomplete?: boolean; truncated?: boolean; + readmeStatus?: number; + treeStatus?: number; + treePaths?: string[]; releaseStatus?: number; checksStatus?: number; failingChecks?: boolean; + networkFailures?: Endpoint[]; + requestLog?: Endpoint[]; } const readme = `# Complete project @@ -68,10 +74,13 @@ function endpointFor(url: string): Endpoint { function mockFetcher(options: MockOptions = {}): typeof fetch { return (async (input: string | URL | Request) => { - if (options.networkFailure) throw new TypeError("offline"); const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; const endpoint = endpointFor(url); + options.requestLog?.push(endpoint); + if (options.networkFailure || options.networkFailures?.includes(endpoint)) { + throw new TypeError("offline"); + } const remaining = options.remaining ?? 50; if (endpoint === "metadata") { @@ -92,7 +101,7 @@ function mockFetcher(options: MockOptions = {}): typeof fetch { default_branch: "main", archived: options.archived ?? false, fork: options.fork ?? false, - size: options.empty ? 0 : 250, + size: options.empty || options.sizeZero ? 0 : 250, topics: options.incomplete ? [] : ["testing", "typescript", "open-source"], }, 200, @@ -129,6 +138,9 @@ function mockFetcher(options: MockOptions = {}): typeof fetch { } if (endpoint === "readme") { + if (options.readmeStatus && options.readmeStatus !== 200) { + return jsonResponse({ message: "Not Found" }, options.readmeStatus, remaining); + } if (options.incomplete || options.empty) return jsonResponse({ message: "Not Found" }, 404, remaining); return jsonResponse( @@ -143,24 +155,36 @@ function mockFetcher(options: MockOptions = {}): typeof fetch { } if (endpoint === "tree") { - const paths = options.incomplete - ? ["package.json"] - : [ - ".github/workflows/ci.yml", - ".github/ISSUE_TEMPLATE/bug.yml", - ".github/PULL_REQUEST_TEMPLATE.md", - "CHANGELOG.md", - "CODE_OF_CONDUCT.md", - "CONTRIBUTING.md", - "LICENSE", - "README.md", - "SECURITY.md", - "eslint.config.mjs", - "package-lock.json", - "package.json", - "src/app.test.ts", - "tsconfig.json", - ]; + if (options.empty) { + return jsonResponse({ message: "Git Repository is empty." }, 409, remaining); + } + if (options.treeStatus && options.treeStatus !== 200) { + return jsonResponse( + { message: options.treeStatus === 409 ? "Git Repository is empty." : "Unavailable" }, + options.treeStatus, + remaining, + ); + } + const paths = + options.treePaths ?? + (options.incomplete + ? ["package.json"] + : [ + ".github/workflows/ci.yml", + ".github/ISSUE_TEMPLATE/bug.yml", + ".github/PULL_REQUEST_TEMPLATE.md", + "CHANGELOG.md", + "CODE_OF_CONDUCT.md", + "CONTRIBUTING.md", + "LICENSE", + "README.md", + "SECURITY.md", + "eslint.config.mjs", + "package-lock.json", + "package.json", + "src/app.test.ts", + "tsconfig.json", + ]); return jsonResponse( { truncated: options.truncated ?? false, @@ -189,6 +213,9 @@ function mockFetcher(options: MockOptions = {}): typeof fetch { ); } + if (options.empty) { + return jsonResponse({ message: "Git Repository is empty." }, 409, remaining); + } if (options.checksStatus && options.checksStatus !== 200) { return jsonResponse({ message: "Unavailable" }, options.checksStatus, remaining); } @@ -258,11 +285,61 @@ describe("GitHub collection integration", () => { expect(report.notices.join(" ")).toMatch(/fork/i); }); - it("handles empty repositories without requesting a tree or commit checks", async () => { - const facts = await collect({ empty: true }); + it("does not classify a size-zero repository with a populated tree as empty", async () => { + const facts = await collect({ sizeZero: true, readmeStatus: 404 }); + expect(facts.empty).toBe(false); + expect(facts.tree.paths).toContain(".github/workflows/ci.yml"); + }); + + it("does not classify a size-zero repository with a README as empty", async () => { + const facts = await collect({ sizeZero: true, treeStatus: 409 }); + expect(facts.empty).toBe(false); + expect(facts.readme.state).toBe("known"); + }); + + it("detects launch evidence in a size-zero repository tree", async () => { + const report = calculateReport(await collect({ sizeZero: true, readmeStatus: 404 })); + const statuses = new Map(report.checks.map((check) => [check.id, check.status])); + + expect(statuses.get("engineering.ci")).toBe("passed"); + expect(statuses.get("engineering.tests")).toBe("passed"); + expect(statuses.get("engineering.tooling")).toBe("passed"); + expect(statuses.get("engineering.lockfile")).toBe("passed"); + expect(statuses.get("community.security")).toBe("passed"); + expect(statuses.get("community.issue_template")).toBe("passed"); + expect(statuses.get("release.changelog")).toBe("passed"); + }); + + it("confirms a genuinely empty repository and still attempts tree and checks", async () => { + const requestLog: Endpoint[] = []; + const facts = await collect({ empty: true, requestLog }); expect(facts.empty).toBe(true); expect(facts.tree).toMatchObject({ state: "known", paths: [] }); expect(facts.checks.state).toBe("unknown"); + expect(requestLog).toContain("tree"); + expect(requestLog).toContain("checks"); + }); + + it("does not claim a size-zero repository is empty when evidence requests fail", async () => { + const facts = await collect({ + sizeZero: true, + networkFailures: ["readme", "tree", "checks"], + }); + expect(facts.empty).toBe(false); + expect(facts.readme.state).toBe("unknown"); + expect(facts.tree.state).toBe("unknown"); + expect(facts.checks.state).toBe("unknown"); + }); + + it("does not claim a size-zero repository is empty from a truncated empty tree", async () => { + const facts = await collect({ + sizeZero: true, + readmeStatus: 404, + truncated: true, + treePaths: [], + }); + expect(facts.empty).toBe(false); + expect(facts.tree.state).toBe("unknown"); }); it("surfaces exhausted public API limits", async () => { diff --git a/src/api/collectRepositoryFacts.ts b/src/api/collectRepositoryFacts.ts index 0ed7f9a..0ac7778 100644 --- a/src/api/collectRepositoryFacts.ts +++ b/src/api/collectRepositoryFacts.ts @@ -27,19 +27,55 @@ import type { import type { RepositoryRef } from "../types/report"; type EndpointResult = - { ok: true; data: T } | { ok: false; kind: GitHubApiError["kind"]; reason: string }; + | { ok: true; data: T } + | { + ok: false; + kind: GitHubApiError["kind"]; + reason: string; + status: number | null; + apiMessage: string | null; + }; async function settle(promise: Promise): Promise> { try { return { ok: true, data: await promise }; } catch (error) { if (error instanceof GitHubApiError) { - return { ok: false, kind: error.kind, reason: error.message }; + return { + ok: false, + kind: error.kind, + reason: error.message, + status: error.status, + apiMessage: error.apiMessage, + }; } - return { ok: false, kind: "network", reason: "This GitHub evidence could not be retrieved." }; + return { + ok: false, + kind: "network", + reason: "This GitHub evidence could not be retrieved.", + status: null, + apiMessage: null, + }; } } +function confirmsEmptyRepository( + repositorySize: number, + readme: EndpointResult, + tree: EndpointResult, +): boolean { + if (repositorySize !== 0 || readme.ok || readme.kind !== "not-found") return false; + if (tree.ok) return !tree.data.truncated && tree.data.tree.length === 0; + + return ( + tree.kind === "conflict" && + tree.status === 409 && + /(?:git repository is empty|repository is empty|no default-branch commit|no commits?)/i.test( + tree.apiMessage ?? "", + ) + ); +} + function decodeBase64Utf8(value: string): string { const compact = value.replace(/\s/g, ""); const bytes = Uint8Array.from(atob(compact), (character) => character.charCodeAt(0)); @@ -207,36 +243,26 @@ export async function collectRepositoryFacts( ): Promise { const base = `/repos/${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.repo)}`; const repository = await client.request(base, repositorySchema, signal); - const empty = repository.size === 0; - const treePromise: Promise> = empty - ? Promise.resolve({ ok: true, data: { truncated: false, tree: [] } }) - : settle( - client.request( - `${base}/git/trees/${encodeURIComponent(repository.default_branch)}?recursive=1`, - treeSchema, - signal, - ), - ); - const [community, readme, tree, release, checks] = await Promise.all([ settle(client.request(`${base}/community/profile`, communityProfileSchema, signal)), settle(client.request(`${base}/readme`, readmeSchema, signal)), - treePromise, + settle( + client.request( + `${base}/git/trees/${encodeURIComponent(repository.default_branch)}?recursive=1`, + treeSchema, + signal, + ), + ), settle(client.request(`${base}/releases/latest`, releaseSchema, signal)), - empty - ? Promise.resolve>({ - ok: false, - kind: "not-found", - reason: "An empty repository has no default-branch commit to inspect.", - }) - : settle( - client.request( - `${base}/commits/${encodeURIComponent(repository.default_branch)}/check-runs?per_page=100`, - checkRunsSchema, - signal, - ), - ), + settle( + client.request( + `${base}/commits/${encodeURIComponent(repository.default_branch)}/check-runs?per_page=100`, + checkRunsSchema, + signal, + ), + ), ]); + const empty = confirmsEmptyRepository(repository.size, readme, tree); return { ref, diff --git a/src/api/githubClient.ts b/src/api/githubClient.ts index dd7f7ad..b8748e6 100644 --- a/src/api/githubClient.ts +++ b/src/api/githubClient.ts @@ -4,7 +4,14 @@ import type { RateLimitInfo } from "../types/report"; const API_ROOT = "https://api.github.com"; export type GitHubErrorKind = - "not-found" | "rate-limit" | "forbidden" | "server" | "malformed" | "network" | "aborted"; + | "not-found" + | "rate-limit" + | "forbidden" + | "conflict" + | "server" + | "malformed" + | "network" + | "aborted"; export class GitHubApiError extends Error { constructor( @@ -12,6 +19,7 @@ export class GitHubApiError extends Error { message: string, public readonly status: number | null = null, public readonly rateLimit: RateLimitInfo | null = null, + public readonly apiMessage: string | null = null, ) { super(message); this.name = "GitHubApiError"; @@ -38,13 +46,18 @@ function rateLimitFromResponse(response: Response): RateLimitInfo { }; } -function responseError(response: Response, rateLimit: RateLimitInfo): GitHubApiError { +function responseError( + response: Response, + rateLimit: RateLimitInfo, + apiMessage: string | null, +): GitHubApiError { if (response.status === 404) { return new GitHubApiError( "not-found", "Repository not found or not publicly accessible.", 404, rateLimit, + apiMessage, ); } if (response.status === 429 || (response.status === 403 && rateLimit.remaining === 0)) { @@ -53,6 +66,7 @@ function responseError(response: Response, rateLimit: RateLimitInfo): GitHubApiE "GitHub’s public API limit is exhausted. Try again after the reset time.", response.status, rateLimit, + apiMessage, ); } if (response.status === 403) { @@ -61,6 +75,16 @@ function responseError(response: Response, rateLimit: RateLimitInfo): GitHubApiE "GitHub refused this public request. The repository may be restricted or temporarily unavailable.", 403, rateLimit, + apiMessage, + ); + } + if (response.status === 409) { + return new GitHubApiError( + "conflict", + "GitHub reports that the requested repository state is unavailable.", + 409, + rateLimit, + apiMessage, ); } if (response.status >= 500) { @@ -69,6 +93,7 @@ function responseError(response: Response, rateLimit: RateLimitInfo): GitHubApiE "GitHub is temporarily unavailable. No missing evidence was scored as a failure.", response.status, rateLimit, + apiMessage, ); } return new GitHubApiError( @@ -76,6 +101,7 @@ function responseError(response: Response, rateLimit: RateLimitInfo): GitHubApiE `GitHub returned an unexpected response (${response.status}).`, response.status, rateLimit, + apiMessage, ); } @@ -105,7 +131,16 @@ export function createGitHubClient(fetcher: typeof fetch = fetch): GitHubClient } latestRateLimit = rateLimitFromResponse(response); - if (!response.ok) throw responseError(response, latestRateLimit); + if (!response.ok) { + let apiMessage: string | null = null; + try { + const errorBody = (await response.json()) as { message?: unknown }; + apiMessage = typeof errorBody.message === "string" ? errorBody.message : null; + } catch { + // HTTP status still provides a safe fallback classification. + } + throw responseError(response, latestRateLimit, apiMessage); + } let raw: unknown; try { diff --git a/src/api/reportCache.test.ts b/src/api/reportCache.test.ts new file mode 100644 index 0000000..64dd337 --- /dev/null +++ b/src/api/reportCache.test.ts @@ -0,0 +1,29 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { csvHealthFixture } from "../fixtures/csvHealthFixture"; +import { calculateReport } from "../scoring/calculateScore"; +import { readCachedReport, writeCachedReport } from "./reportCache"; + +const ref = csvHealthFixture.ref; +const report = calculateReport(csvHealthFixture, { + analyzedAt: "2026-07-20T12:00:00.000Z", + source: "github", +}); + +describe("report cache", () => { + beforeEach(() => sessionStorage.clear()); + + it("reads reports written with the current cache schema", () => { + writeCachedReport(ref, report, sessionStorage, 1_000); + expect(readCachedReport(ref, sessionStorage, 2_000)).toEqual(report); + expect(sessionStorage.key(0)).toContain("repo-ready:cache-v2:"); + }); + + it("does not reuse reports from the previous unversioned cache key", () => { + sessionStorage.setItem( + `repo-ready:${report.rulesetVersion}:${ref.fullName.toLowerCase()}`, + JSON.stringify({ expiresAt: 999_999, report }), + ); + + expect(readCachedReport(ref, sessionStorage, 2_000)).toBeNull(); + }); +}); diff --git a/src/api/reportCache.ts b/src/api/reportCache.ts index 2f5783d..98fb1cf 100644 --- a/src/api/reportCache.ts +++ b/src/api/reportCache.ts @@ -2,14 +2,16 @@ import type { ReadinessReport, RepositoryRef } from "../types/report"; import { RULESET_VERSION } from "../types/report"; const CACHE_TTL_MS = 10 * 60 * 1000; +const CACHE_SCHEMA_VERSION = 2; interface CacheEntry { + schemaVersion: number; expiresAt: number; report: ReadinessReport; } function key(ref: RepositoryRef): string { - return `repo-ready:${RULESET_VERSION}:${ref.fullName.toLowerCase()}`; + return `repo-ready:cache-v${CACHE_SCHEMA_VERSION}:${RULESET_VERSION}:${ref.fullName.toLowerCase()}`; } export function readCachedReport( @@ -23,6 +25,7 @@ export function readCachedReport( if (!raw) return null; const parsed = JSON.parse(raw) as Partial; if ( + parsed.schemaVersion !== CACHE_SCHEMA_VERSION || typeof parsed.expiresAt !== "number" || parsed.expiresAt <= now || !parsed.report || @@ -46,7 +49,11 @@ export function writeCachedReport( now = Date.now(), ): void { try { - const entry: CacheEntry = { expiresAt: now + CACHE_TTL_MS, report }; + const entry: CacheEntry = { + schemaVersion: CACHE_SCHEMA_VERSION, + expiresAt: now + CACHE_TTL_MS, + report, + }; storage.setItem(key(ref), JSON.stringify(entry)); } catch { // Storage may be disabled or full. Reports still work without caching. diff --git a/src/fixtures/csvHealthFixture.ts b/src/fixtures/csvHealthFixture.ts index 3486ed3..1ef68aa 100644 --- a/src/fixtures/csvHealthFixture.ts +++ b/src/fixtures/csvHealthFixture.ts @@ -7,7 +7,7 @@ export const csvHealthFixture: RepositoryFacts = { description: "A privacy-first browser tool that profiles CSV files and explains data-quality issues.", topics: ["csv", "data-quality", "react", "typescript", "privacy"], - homepage: "https://aminhanifm.github.io/CSV-Health/", + homepage: "https://aminhanifm.github.io/projects/csv-health/", language: "TypeScript", defaultBranch: "main", archived: false, @@ -24,7 +24,7 @@ CSV Health is a privacy-first browser tool for developers and analysts who need ![CSV Health overview](docs/screenshots/csv-health-overview.jpg) -[Try the live demo](https://aminhanifm.github.io/CSV-Health/) +[Try the live demo](https://aminhanifm.github.io/projects/csv-health/) ## Quick start diff --git a/src/scoring/rules.test.ts b/src/scoring/rules.test.ts index 2793bfd..c68d4e6 100644 --- a/src/scoring/rules.test.ts +++ b/src/scoring/rules.test.ts @@ -1,3 +1,5 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; import { describe, expect, it } from "vitest"; import { csvHealthFixture } from "../fixtures/csvHealthFixture"; import { incompleteRepositoryFixture } from "../fixtures/incompleteRepositoryFixture"; @@ -93,6 +95,15 @@ describe("scoring rules", () => { expect(report.score).toBe(100); expect(report.coverage).toBe(89); }); + + it("recognizes the project README's end-to-end usage section", () => { + const facts = cloneFacts(); + facts.readme.text = readFileSync(resolve(process.cwd(), "README.md"), "utf8"); + const usageRule = SCORING_RULES.find((rule) => rule.id === "documentation.usage"); + + expect(usageRule).toBeDefined(); + expect(evaluateRule(usageRule!, facts).status).toBe("passed"); + }); }); describe("readiness labels", () => {