diff --git a/.codex/steering/repository-steering.md b/.codex/steering/repository-steering.md index be9f6c1..4978559 100644 --- a/.codex/steering/repository-steering.md +++ b/.codex/steering/repository-steering.md @@ -13,6 +13,7 @@ Core priorities: - no server-side repository data storage - stable shared TypeScript contracts - stable typed contracts between modules +- config/constant driven behavior instead of repeated magic strings or values - maintainable local workflows ## Active Boundaries @@ -41,3 +42,11 @@ Safe default changes: - endpoint hardening and validation - focused test additions - typing improvements + +## Configuration Standards + +- Prefer named constants or typed configuration objects for route paths, storage keys, cookie names, rate limits, TTLs, security headers, score weights, category rules, labels, and user-facing status text. +- Keep constants private to their owning module when only one module uses them. +- Promote constants to shared typed config modules when values are reused across modules, packages, workers, or tests. +- Avoid duplicating string literals across runtime code and tests; tests should import stable constants or assert behavior through public APIs when possible. +- Treat scoring and category changes as config changes first, then algorithm changes only when config cannot express the behavior clearly. diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 234ef1b..dffa1f2 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -42,5 +42,41 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Run quality gate - run: npm run check + - name: Check scripts + run: npm run check:scripts + + - name: Check pre-MVP syntax + run: npm run check:pre-mvp + + - name: Check analysis review fixture + run: npm run check:analysis + + - name: Check docs + run: npm run check:docs + + - name: Check workspace + run: npm run check:workspace + + - name: Test packages with coverage + run: npm run test:coverage + + - name: Test web app + run: npm run check:web-tests + + - name: Test Worker + run: npm run check:worker-tests + + - name: Lint + run: npm run lint + + - name: Check Astro formatting + run: npm run format:astro:check + + - name: Typecheck + run: npm run typecheck + + - name: Build + run: npm run build + + - name: Smoke test web build + run: npm run check:web-smoke diff --git a/apps/web/src/lib/analysis-result-store.ts b/apps/web/src/lib/analysis-result-store.ts new file mode 100644 index 0000000..0744698 --- /dev/null +++ b/apps/web/src/lib/analysis-result-store.ts @@ -0,0 +1,10 @@ +import type { RepositoryAnalysis } from "@forage/shared"; +import { db } from "./db-schema"; + +export async function saveAnalysisResults(results: RepositoryAnalysis[]) { + await db.analysisResults.bulkPut(results); +} + +export async function getAllAnalysisResults() { + return db.analysisResults.toArray(); +} diff --git a/apps/web/src/lib/db-schema.ts b/apps/web/src/lib/db-schema.ts new file mode 100644 index 0000000..0829589 --- /dev/null +++ b/apps/web/src/lib/db-schema.ts @@ -0,0 +1,42 @@ +import type { ForageRepository, ImportEvent, RepositoryAnalysis } from "@forage/shared"; +import Dexie, { type EntityTable } from "dexie"; + +export const localLibraryProfileKey = "local-library-profile"; +export const localOperationLockKey = "local-operation-lock"; + +export type LocalOperationName = "import" | "reset"; + +export interface LocalLibraryProfile { + id: typeof localLibraryProfileKey; + github_login: string | null; + github_user_id: number | null; + repository_count: number; + updated_at: string; +} + +export interface LocalOperationLock { + id: typeof localOperationLockKey; + owner_id: string; + operation: LocalOperationName; + acquired_at: string; + heartbeat_at: string; + expires_at: string; +} + +type MetadataRecord = LocalLibraryProfile | LocalOperationLock; + +interface ForageDatabase extends Dexie { + repositories: EntityTable; + importEvents: EntityTable; + metadata: EntityTable; + analysisResults: EntityTable; +} + +export const db = new Dexie("forage") as ForageDatabase; + +db.version(3).stores({ + repositories: "github_id, &full_name, primary_language, starred_at", + importEvents: "id", + metadata: "id", + analysisResults: "repository_id, &repository_full_name, analysis_version", +}); diff --git a/apps/web/src/lib/db.ts b/apps/web/src/lib/db.ts index 90e49a7..4fb75ef 100644 --- a/apps/web/src/lib/db.ts +++ b/apps/web/src/lib/db.ts @@ -1,196 +1,16 @@ -import type { ForageRepository, ImportEvent, RepositoryAnalysis } from "@forage/shared"; -import Dexie, { type EntityTable } from "dexie"; - -const localLibraryProfileKey = "local-library-profile"; -const localOperationLockKey = "local-operation-lock"; - -export type LocalOperationName = "import" | "reset"; - -export interface LocalLibraryProfile { - id: typeof localLibraryProfileKey; - github_login: string | null; - github_user_id: number | null; - repository_count: number; - updated_at: string; -} - -export interface LocalOperationLock { - id: typeof localOperationLockKey; - owner_id: string; - operation: LocalOperationName; - acquired_at: string; - heartbeat_at: string; - expires_at: string; -} - -type MetadataRecord = LocalLibraryProfile | LocalOperationLock; - -interface ForageDatabase extends Dexie { - repositories: EntityTable; - importEvents: EntityTable; - metadata: EntityTable; - analysisResults: EntityTable; -} - -const db = new Dexie("forage") as ForageDatabase; - -db.version(3).stores({ - repositories: "github_id, &full_name, primary_language, starred_at", - importEvents: "id", - metadata: "id", - analysisResults: "repository_id, &repository_full_name, analysis_version", -}); - -export async function saveRepositories(repositories: ForageRepository[]) { - await db.repositories.bulkPut(repositories); -} - -export async function getAllRepositories() { - return db.repositories.toArray(); -} - -export async function saveImportEvent(event: ImportEvent) { - await db.importEvents.put(event); -} - -export async function getImportEvents() { - const events = await db.importEvents.toArray(); - return events.sort((left, right) => right.started_at.localeCompare(left.started_at)); -} - -export async function saveAnalysisResults(results: RepositoryAnalysis[]) { - await db.analysisResults.bulkPut(results); -} - -export async function getAllAnalysisResults() { - return db.analysisResults.toArray(); -} - -export async function reconcileImportedRepositories(importedGithubIds: Iterable) { - const retainedIds = new Set(importedGithubIds); - - await db.transaction("rw", db.repositories, db.analysisResults, async () => { - const [repositories, analysisResults] = await Promise.all([ - db.repositories.toArray(), - db.analysisResults.toArray(), - ]); - const staleRepositoryIds = repositories - .map((repository) => repository.github_id) - .filter((githubId) => !retainedIds.has(githubId)); - const staleAnalysisIds = analysisResults - .map((analysis) => analysis.repository_id) - .filter((repositoryId) => !retainedIds.has(repositoryId)); - - await Promise.all([ - db.repositories.bulkDelete(staleRepositoryIds), - db.analysisResults.bulkDelete(staleAnalysisIds), - ]); - }); -} - -export async function saveLocalLibraryProfile(profile: Omit) { - await db.metadata.put({ ...profile, id: localLibraryProfileKey }); -} - -export async function getLocalLibraryProfile() { - return ( - ((await db.metadata.get(localLibraryProfileKey)) as LocalLibraryProfile | undefined) ?? null - ); -} - -export async function acquireLocalOperationLock( - operation: LocalOperationName, - { - ttlMs = 15 * 60 * 1000, - now = new Date(), - ownerId = crypto.randomUUID(), - }: { - ttlMs?: number; - now?: Date; - ownerId?: string; - } = {}, -) { - const nowMs = now.getTime(); - const lock: LocalOperationLock = { - id: localOperationLockKey, - owner_id: ownerId, - operation, - acquired_at: now.toISOString(), - heartbeat_at: now.toISOString(), - expires_at: new Date(nowMs + ttlMs).toISOString(), - }; - - const blockingLock = await db.transaction("rw", db.metadata, async () => { - const existing = (await db.metadata.get(localOperationLockKey)) as - | LocalOperationLock - | undefined; - if (existing && Date.parse(existing.expires_at) > nowMs) return existing; - - await db.metadata.put(lock); - return null; - }); - - return blockingLock - ? { - acquired: false as const, - lock: blockingLock, - } - : { - acquired: true as const, - lock, - }; -} - -export async function refreshLocalOperationLock( - ownerId: string, - { - ttlMs = 15 * 60 * 1000, - now = new Date(), - }: { - ttlMs?: number; - now?: Date; - } = {}, -) { - await db.transaction("rw", db.metadata, async () => { - const existing = (await db.metadata.get(localOperationLockKey)) as - | LocalOperationLock - | undefined; - if (!existing || existing.owner_id !== ownerId) return; - - const refreshedLock: LocalOperationLock = { - ...existing, - heartbeat_at: now.toISOString(), - expires_at: new Date(now.getTime() + ttlMs).toISOString(), - }; - await db.metadata.put(refreshedLock); - }); -} - -export async function releaseLocalOperationLock(ownerId: string) { - await db.transaction("rw", db.metadata, async () => { - const existing = (await db.metadata.get(localOperationLockKey)) as - | LocalOperationLock - | undefined; - if (existing?.owner_id === ownerId) { - await db.metadata.delete(localOperationLockKey); - } - }); -} - -export async function resetLocalData() { - await db.transaction( - "rw", - db.repositories, - db.importEvents, - db.analysisResults, - db.metadata, - async () => { - await Promise.all([ - db.repositories.clear(), - db.importEvents.clear(), - db.analysisResults.clear(), - db.metadata.clear(), - ]); - }, - ); -} +export { getAllAnalysisResults, saveAnalysisResults } from "./analysis-result-store"; +export type { LocalLibraryProfile, LocalOperationLock, LocalOperationName } from "./db-schema"; +export { getImportEvents, saveImportEvent } from "./import-event-store"; +export { resetLocalData } from "./local-data-store"; +export { getLocalLibraryProfile, saveLocalLibraryProfile } from "./local-profile-store"; +export { + acquireLocalOperationLock, + defaultLocalOperationLockTtlMs, + refreshLocalOperationLock, + releaseLocalOperationLock, +} from "./operation-lock-store"; +export { + getAllRepositories, + reconcileImportedRepositories, + saveRepositories, +} from "./repository-store"; diff --git a/apps/web/src/lib/import-event-store.ts b/apps/web/src/lib/import-event-store.ts new file mode 100644 index 0000000..d80233d --- /dev/null +++ b/apps/web/src/lib/import-event-store.ts @@ -0,0 +1,11 @@ +import type { ImportEvent } from "@forage/shared"; +import { db } from "./db-schema"; + +export async function saveImportEvent(event: ImportEvent) { + await db.importEvents.put(event); +} + +export async function getImportEvents() { + const events = await db.importEvents.toArray(); + return events.sort((left, right) => right.started_at.localeCompare(left.started_at)); +} diff --git a/apps/web/src/lib/local-data-store.ts b/apps/web/src/lib/local-data-store.ts new file mode 100644 index 0000000..28a2da4 --- /dev/null +++ b/apps/web/src/lib/local-data-store.ts @@ -0,0 +1,19 @@ +import { db } from "./db-schema"; + +export async function resetLocalData() { + await db.transaction( + "rw", + db.repositories, + db.importEvents, + db.analysisResults, + db.metadata, + async () => { + await Promise.all([ + db.repositories.clear(), + db.importEvents.clear(), + db.analysisResults.clear(), + db.metadata.clear(), + ]); + }, + ); +} diff --git a/apps/web/src/lib/local-profile-store.ts b/apps/web/src/lib/local-profile-store.ts new file mode 100644 index 0000000..fb11260 --- /dev/null +++ b/apps/web/src/lib/local-profile-store.ts @@ -0,0 +1,11 @@ +import { db, type LocalLibraryProfile, localLibraryProfileKey } from "./db-schema"; + +export async function saveLocalLibraryProfile(profile: Omit) { + await db.metadata.put({ ...profile, id: localLibraryProfileKey }); +} + +export async function getLocalLibraryProfile() { + return ( + ((await db.metadata.get(localLibraryProfileKey)) as LocalLibraryProfile | undefined) ?? null + ); +} diff --git a/apps/web/src/lib/operation-lock-store.ts b/apps/web/src/lib/operation-lock-store.ts new file mode 100644 index 0000000..8a5afb8 --- /dev/null +++ b/apps/web/src/lib/operation-lock-store.ts @@ -0,0 +1,87 @@ +import { + db, + type LocalOperationLock, + type LocalOperationName, + localOperationLockKey, +} from "./db-schema"; + +export const defaultLocalOperationLockTtlMs = 15 * 60 * 1000; + +export async function acquireLocalOperationLock( + operation: LocalOperationName, + { + ttlMs = defaultLocalOperationLockTtlMs, + now = new Date(), + ownerId = crypto.randomUUID(), + }: { + ttlMs?: number; + now?: Date; + ownerId?: string; + } = {}, +) { + const nowMs = now.getTime(); + const lock: LocalOperationLock = { + id: localOperationLockKey, + owner_id: ownerId, + operation, + acquired_at: now.toISOString(), + heartbeat_at: now.toISOString(), + expires_at: new Date(nowMs + ttlMs).toISOString(), + }; + + const blockingLock = await db.transaction("rw", db.metadata, async () => { + const existing = (await db.metadata.get(localOperationLockKey)) as + | LocalOperationLock + | undefined; + if (existing && Date.parse(existing.expires_at) > nowMs) return existing; + + await db.metadata.put(lock); + return null; + }); + + return blockingLock + ? { + acquired: false as const, + lock: blockingLock, + } + : { + acquired: true as const, + lock, + }; +} + +export async function refreshLocalOperationLock( + ownerId: string, + { + ttlMs = defaultLocalOperationLockTtlMs, + now = new Date(), + }: { + ttlMs?: number; + now?: Date; + } = {}, +) { + await db.transaction("rw", db.metadata, async () => { + const existing = (await db.metadata.get(localOperationLockKey)) as + | LocalOperationLock + | undefined; + if (!existing || existing.owner_id !== ownerId) return; + + const refreshedLock: LocalOperationLock = { + ...existing, + heartbeat_at: now.toISOString(), + expires_at: new Date(now.getTime() + ttlMs).toISOString(), + }; + await db.metadata.put(refreshedLock); + }); +} + +export async function releaseLocalOperationLock(ownerId: string) { + await db.transaction("rw", db.metadata, async () => { + const existing = (await db.metadata.get(localOperationLockKey)) as + | LocalOperationLock + | undefined; + if (existing?.owner_id === ownerId) { + await db.metadata.delete(localOperationLockKey); + } + }); +} diff --git a/apps/web/src/lib/repository-store.ts b/apps/web/src/lib/repository-store.ts new file mode 100644 index 0000000..87c28d8 --- /dev/null +++ b/apps/web/src/lib/repository-store.ts @@ -0,0 +1,32 @@ +import type { ForageRepository } from "@forage/shared"; +import { db } from "./db-schema"; + +export async function saveRepositories(repositories: ForageRepository[]) { + await db.repositories.bulkPut(repositories); +} + +export async function getAllRepositories() { + return db.repositories.toArray(); +} + +export async function reconcileImportedRepositories(importedGithubIds: Iterable) { + const retainedIds = new Set(importedGithubIds); + + await db.transaction("rw", db.repositories, db.analysisResults, async () => { + const [repositories, analysisResults] = await Promise.all([ + db.repositories.toArray(), + db.analysisResults.toArray(), + ]); + const staleRepositoryIds = repositories + .map((repository) => repository.github_id) + .filter((githubId) => !retainedIds.has(githubId)); + const staleAnalysisIds = analysisResults + .map((analysis) => analysis.repository_id) + .filter((repositoryId) => !retainedIds.has(repositoryId)); + + await Promise.all([ + db.repositories.bulkDelete(staleRepositoryIds), + db.analysisResults.bulkDelete(staleAnalysisIds), + ]); + }); +} diff --git a/docs/19-code-audit.md b/docs/19-code-audit.md index 625ad19..c8c2cd3 100644 --- a/docs/19-code-audit.md +++ b/docs/19-code-audit.md @@ -1,26 +1,35 @@ # Code Audit Status: -Initial reliability audit after the Astro/Svelte island migration. +Refactor follow-up notes after the Astro/Svelte island migration, local reliability work, and Worker module split. Audit date: June 6, 2026 -Primary concerns found: +Primary concerns found and current status: - Local-first display was too dependent on Worker availability. The app previously loaded Worker config/session and local IndexedDB data in one `Promise.all`; if the Worker was down, already-imported local data could fail to refresh into UI state. This slice separates local data reads from remote auth/config reads. - Import refresh only upserted repositories. Completed refreshes could leave repositories that were no longer starred in the local library. This slice adds local reconciliation after a successful full import. -- The main Svelte island is too broad. `ForageApp.svelte` currently owns auth state, import orchestration, export generation, filtering, derived analysis, settings, theme behavior, and most rendering. This is acceptable for the spike-to-MVP transition but should be split before report-building work grows. +- The main Svelte island was too broad. `ForageApp.svelte` previously owned auth state, import orchestration, export generation, filtering, derived analysis, settings, theme behavior, and most rendering. This has been partially resolved by moving app state, refresh, export, theme, and import pipeline logic into focused modules. +- The Cloudflare Worker entrypoint was too broad. `apps/worker/src/index.ts` previously owned routing, OAuth, sessions, settings, CORS/security headers, rate limiting, crypto helpers, storage coordination, GitHub proxying, and the Durable Object. This has been resolved by splitting route handlers, support modules, and Durable Object logic by concern. - Import and reset now use a browser-wide local data lock. The app prefers the Web Locks API and falls back to an IndexedDB metadata lock with stale-lock expiry. -- The Worker session store is in-memory for local development. This is fine for the current local app loop, but production deployment needs explicit Cloudflare storage/session design before it is relied on. +- The Worker session store uses in-memory storage only when Cloudflare storage bindings are absent for local development. Hosted deployments should use the Durable Object-backed encrypted session path. - Local database migrations are still minimal. Dexie now owns the browser database wrapper, but we should treat any schema version bump as a planned migration with a fixture-backed test. +Current refactor standards: + +- Prefer configuration and named constants over inline magic strings, route paths, storage keys, score weights, limits, TTLs, labels, and status text. +- Keep constants close to the owning module when they are private implementation details, and promote them to a typed config module when shared across handlers, workers, packages, or tests. +- Make user-facing behavior changes through typed config structures where possible, especially category rules, scoring weights, rate limits, storage keys, and security header policy. +- Pair config extraction with focused tests so changing a config value has an obvious expected behavior. + Near-term design recommendations: -- Split `ForageApp.svelte` into smaller components before adding report views: import controls, library filters, repository list, metrics, settings, and app-state/domain helpers. -- Move pure library filtering/sorting helpers out of the component and test them directly. +- Continue splitting `ForageApp.svelte` only when new UI surfaces add real complexity. The current app shell is acceptable after the orchestration split. - Keep import refresh as full reconciliation for MVP. Add incremental refresh only after there is a measured need and a clear stale-star detection model. - Add restore/import-from-export only after JSON schema validation is explicit and tested. +- Keep local schema migration work fixture-backed now that the browser database is split into a facade plus focused store modules. +- Treat future category and scoring tuning as config changes first now that analysis versions, category rules, scoring weights, thresholds, and label cutoffs are in typed config modules. Resolved in this slice: @@ -29,3 +38,7 @@ Resolved in this slice: - Kept failed and cancelled imports from pruning existing local data. - Added browser-wide locking around import and reset workflows. - Updated import/storage docs to match the current implementation. +- Split web app orchestration, export, theme, refresh, and import pipeline logic into focused modules. +- Split Worker support modules, route handlers, Durable Object logic, and test helpers by concern. +- Split browser local data storage into a public DB facade plus schema, repository, import-event, analysis-result, profile, operation-lock, and reset modules. +- Extracted analysis plan/version constants, category rules, and scoring weights/thresholds into typed config modules. diff --git a/packages/analysis/src/analysis-config.ts b/packages/analysis/src/analysis-config.ts new file mode 100644 index 0000000..c24d406 --- /dev/null +++ b/packages/analysis/src/analysis-config.ts @@ -0,0 +1,15 @@ +export interface AnalysisPlan { + category_model: "weighted-rules"; + scoring_model: "user-agnostic-foundational"; + personalization: "deferred-match-score"; +} + +export const analysisPlan: AnalysisPlan = { + category_model: "weighted-rules", + scoring_model: "user-agnostic-foundational", + personalization: "deferred-match-score", +}; + +export const analysisVersion = "analysis-v0.1.0"; +export const categoryRulesVersion = "category-rules-v0.1.0"; +export const scoreVersion = "foundational-v0.1.0"; diff --git a/packages/analysis/src/category-config.ts b/packages/analysis/src/category-config.ts new file mode 100644 index 0000000..1c81452 --- /dev/null +++ b/packages/analysis/src/category-config.ts @@ -0,0 +1,272 @@ +import type { CategoryRule } from "@forage/shared"; + +const languageRuleThreshold = 2; +const languageRuleWeight = 2; +const broadCategoryThreshold = 3; +const supportingSignalWeight = 2; +const primarySignalWeight = 1; + +export const defaultCategoryRules: CategoryRule[] = [ + languageRule("language-javascript", "JavaScript", "JavaScript"), + languageRule("language-typescript", "TypeScript", "TypeScript"), + languageRule("language-python", "Python", "Python"), + languageRule("language-go", "Go", "Go"), + languageRule("language-rust", "Rust", "Rust"), + languageRule("language-csharp", "C#", "C#"), + languageRule("language-cpp", "C++", "C++"), + languageRule("language-c", "C", "C"), + languageRule("language-css", "CSS", "CSS"), + languageRule("language-html", "HTML", "HTML"), + languageRule("language-java", "Java", "Java"), + languageRule("language-ruby", "Ruby", "Ruby"), + languageRule("language-shell", "Shell", "Shell"), + languageRule("language-solidity", "Solidity", "Solidity"), + languageRule("language-php", "PHP", "PHP"), + languageRule("language-haskell", "Haskell", "Haskell"), + languageRule("language-vim-script", "Vim Script", "Vim script"), + languageRule("language-nim", "Nim", "Nim"), + languageRule("language-objective-c", "Objective-C", "Objective-C"), + languageRule("language-swift", "Swift", "Swift"), + languageRule("language-kotlin", "Kotlin", "Kotlin"), + languageRule("language-dart", "Dart", "Dart"), + languageRule("language-lua", "Lua", "Lua"), + languageRule("language-elixir", "Elixir", "Elixir"), + languageRule("language-clojure", "Clojure", "Clojure"), + languageRule("language-scala", "Scala", "Scala"), + languageRule("language-assembly", "Assembly", "Assembly"), + languageRule("language-dockerfile", "Dockerfile", "Dockerfile"), + languageRule("language-apacheconf", "ApacheConf", "ApacheConf"), + languageRule("language-zig", "Zig", "Zig"), + languageRule("language-vue", "Vue", "Vue"), + languageRule("language-stylus", "Stylus", "Stylus"), + { + id: "frontend", + label: "Frontend", + family: "frontend", + threshold: broadCategoryThreshold, + languages: terms(["JavaScript", "TypeScript", "CSS", "HTML"], primarySignalWeight), + topics: terms( + ["frontend", "ui", "ux", "css", "html", "react", "vue", "svelte", "astro"], + supportingSignalWeight, + ), + keywords: terms( + ["frontend", "component", "design system", "browser", "web"], + supportingSignalWeight, + ), + }, + { + id: "backend", + label: "Backend", + family: "backend", + threshold: broadCategoryThreshold, + languages: terms(["Go", "Rust", "Python", "Java", "C#", "Ruby", "PHP"], primarySignalWeight), + topics: terms( + ["api", "server", "backend", "database", "queue", "worker"], + supportingSignalWeight, + ), + keywords: terms(["api", "server", "backend", "service", "database"], supportingSignalWeight), + }, + { + id: "developer-tooling", + label: "Developer Tooling", + family: "developer-tooling", + threshold: broadCategoryThreshold, + topics: terms( + [ + "cli", + "lint", + "linters", + "lint-checking", + "syntax-checker", + "automation", + "validation", + "formatter", + "compiler", + "sdk", + "git", + "vim-plugin", + ], + supportingSignalWeight, + ), + keywords: terms( + [ + "cli", + "tool", + "tools", + "utility", + "utilities", + "developer", + "automation", + "lint", + "build", + "compiler", + "git", + ], + supportingSignalWeight, + ), + }, + { + id: "devops", + label: "DevOps", + family: "devops", + threshold: broadCategoryThreshold, + topics: terms( + [ + "docker", + "kubernetes", + "terraform", + "ci", + "cd", + "deploy", + "observability", + "tracing", + "distributed-tracing", + ], + supportingSignalWeight, + ), + keywords: terms( + [ + "deploy", + "container", + "infrastructure", + "pipeline", + "monitoring", + "tracing", + "observability", + ], + supportingSignalWeight, + ), + }, + { + id: "data", + label: "Data", + family: "data", + threshold: broadCategoryThreshold, + languages: terms(["Python", "R", "Julia", "SQL"], primarySignalWeight), + topics: terms( + ["data", "analytics", "machine-learning", "ml", "database", "etl"], + supportingSignalWeight, + ), + keywords: terms( + ["data", "analytics", "machine learning", "database", "dataset"], + supportingSignalWeight, + ), + }, + { + id: "testing", + label: "Testing", + family: "testing", + threshold: broadCategoryThreshold, + topics: terms( + ["test", "testing", "e2e", "mock", "fixture", "playwright", "vitest", "jest"], + supportingSignalWeight, + ), + keywords: terms(["test", "testing", "mock", "fixture", "assertion"], supportingSignalWeight), + }, + { + id: "documentation", + label: "Documentation", + family: "documentation", + threshold: broadCategoryThreshold, + topics: terms( + [ + "docs", + "documentation", + "mdx", + "markdown", + "commonmark", + "markup", + "document", + "publishing", + "static-site", + ], + supportingSignalWeight, + ), + keywords: terms( + ["documentation", "docs", "guide", "manual", "markdown", "markup"], + supportingSignalWeight, + ), + }, + { + id: "security", + label: "Security", + family: "security", + threshold: broadCategoryThreshold, + topics: terms( + ["security", "auth", "oauth", "crypto", "vulnerability", "secrets"], + supportingSignalWeight, + ), + keywords: terms( + ["security", "authentication", "authorization", "oauth", "crypto"], + supportingSignalWeight, + ), + }, + { + id: "learning-resource", + label: "Learning Resource", + family: "learning", + threshold: broadCategoryThreshold, + topics: terms( + [ + "awesome", + "tutorial", + "learning", + "examples", + "course", + "book", + "education", + "learn-to-code", + "training-materials", + "programming", + ], + supportingSignalWeight, + ), + keywords: terms( + [ + "awesome", + "tutorial", + "learn", + "learning", + "resources", + "book", + "guide", + "examples", + "courses", + ], + supportingSignalWeight, + ), + }, + { + id: "library", + label: "Library", + family: "library", + threshold: broadCategoryThreshold, + topics: terms(["library", "package", "sdk", "component", "plugin"], supportingSignalWeight), + keywords: terms( + ["library", "package", "sdk", "framework", "component"], + supportingSignalWeight, + ), + }, + { + id: "application", + label: "Application", + family: "application", + threshold: broadCategoryThreshold, + topics: terms(["app", "application", "desktop-app", "webapp"], supportingSignalWeight), + keywords: terms(["application", "app", "client", "dashboard"], supportingSignalWeight), + }, +]; + +function languageRule(id: string, label: string, language: string): CategoryRule { + return { + id, + label, + family: "language", + threshold: languageRuleThreshold, + languages: terms([language], languageRuleWeight), + }; +} + +function terms(values: string[], weight: number) { + return values.map((value) => ({ value, weight })); +} diff --git a/packages/analysis/src/index.ts b/packages/analysis/src/index.ts index 0de8656..af4545d 100644 --- a/packages/analysis/src/index.ts +++ b/packages/analysis/src/index.ts @@ -1,7 +1,6 @@ import type { CategoryMatch, CategoryMatchReason, - CategoryRule, ForageRepository, InsightLabel, RepositoryAnalysis, @@ -9,243 +8,30 @@ import type { ScoreSet, ScoreValue, } from "@forage/shared"; - -export interface AnalysisPlan { - category_model: "weighted-rules"; - scoring_model: "user-agnostic-foundational"; - personalization: "deferred-match-score"; -} - -export const analysisPlan: AnalysisPlan = { - category_model: "weighted-rules", - scoring_model: "user-agnostic-foundational", - personalization: "deferred-match-score", -}; - -export const analysisVersion = "analysis-v0.1.0"; -export const categoryRulesVersion = "category-rules-v0.1.0"; -export const scoreVersion = "foundational-v0.1.0"; - -export const defaultCategoryRules: CategoryRule[] = [ - languageRule("language-javascript", "JavaScript", "JavaScript"), - languageRule("language-typescript", "TypeScript", "TypeScript"), - languageRule("language-python", "Python", "Python"), - languageRule("language-go", "Go", "Go"), - languageRule("language-rust", "Rust", "Rust"), - languageRule("language-csharp", "C#", "C#"), - languageRule("language-cpp", "C++", "C++"), - languageRule("language-c", "C", "C"), - languageRule("language-css", "CSS", "CSS"), - languageRule("language-html", "HTML", "HTML"), - languageRule("language-java", "Java", "Java"), - languageRule("language-ruby", "Ruby", "Ruby"), - languageRule("language-shell", "Shell", "Shell"), - languageRule("language-solidity", "Solidity", "Solidity"), - languageRule("language-php", "PHP", "PHP"), - languageRule("language-haskell", "Haskell", "Haskell"), - languageRule("language-vim-script", "Vim Script", "Vim script"), - languageRule("language-nim", "Nim", "Nim"), - languageRule("language-objective-c", "Objective-C", "Objective-C"), - languageRule("language-swift", "Swift", "Swift"), - languageRule("language-kotlin", "Kotlin", "Kotlin"), - languageRule("language-dart", "Dart", "Dart"), - languageRule("language-lua", "Lua", "Lua"), - languageRule("language-elixir", "Elixir", "Elixir"), - languageRule("language-clojure", "Clojure", "Clojure"), - languageRule("language-scala", "Scala", "Scala"), - languageRule("language-assembly", "Assembly", "Assembly"), - languageRule("language-dockerfile", "Dockerfile", "Dockerfile"), - languageRule("language-apacheconf", "ApacheConf", "ApacheConf"), - languageRule("language-zig", "Zig", "Zig"), - languageRule("language-vue", "Vue", "Vue"), - languageRule("language-stylus", "Stylus", "Stylus"), - { - id: "frontend", - label: "Frontend", - family: "frontend", - threshold: 3, - languages: terms(["JavaScript", "TypeScript", "CSS", "HTML"], 1), - topics: terms(["frontend", "ui", "ux", "css", "html", "react", "vue", "svelte", "astro"], 2), - keywords: terms(["frontend", "component", "design system", "browser", "web"], 2), - }, - { - id: "backend", - label: "Backend", - family: "backend", - threshold: 3, - languages: terms(["Go", "Rust", "Python", "Java", "C#", "Ruby", "PHP"], 1), - topics: terms(["api", "server", "backend", "database", "queue", "worker"], 2), - keywords: terms(["api", "server", "backend", "service", "database"], 2), - }, - { - id: "developer-tooling", - label: "Developer Tooling", - family: "developer-tooling", - threshold: 3, - topics: terms( - [ - "cli", - "lint", - "linters", - "lint-checking", - "syntax-checker", - "automation", - "validation", - "formatter", - "compiler", - "sdk", - "git", - "vim-plugin", - ], - 2, - ), - keywords: terms( - [ - "cli", - "tool", - "tools", - "utility", - "utilities", - "developer", - "automation", - "lint", - "build", - "compiler", - "git", - ], - 2, - ), - }, - { - id: "devops", - label: "DevOps", - family: "devops", - threshold: 3, - topics: terms( - [ - "docker", - "kubernetes", - "terraform", - "ci", - "cd", - "deploy", - "observability", - "tracing", - "distributed-tracing", - ], - 2, - ), - keywords: terms( - [ - "deploy", - "container", - "infrastructure", - "pipeline", - "monitoring", - "tracing", - "observability", - ], - 2, - ), - }, - { - id: "data", - label: "Data", - family: "data", - threshold: 3, - languages: terms(["Python", "R", "Julia", "SQL"], 1), - topics: terms(["data", "analytics", "machine-learning", "ml", "database", "etl"], 2), - keywords: terms(["data", "analytics", "machine learning", "database", "dataset"], 2), - }, - { - id: "testing", - label: "Testing", - family: "testing", - threshold: 3, - topics: terms(["test", "testing", "e2e", "mock", "fixture", "playwright", "vitest", "jest"], 2), - keywords: terms(["test", "testing", "mock", "fixture", "assertion"], 2), - }, - { - id: "documentation", - label: "Documentation", - family: "documentation", - threshold: 3, - topics: terms( - [ - "docs", - "documentation", - "mdx", - "markdown", - "commonmark", - "markup", - "document", - "publishing", - "static-site", - ], - 2, - ), - keywords: terms(["documentation", "docs", "guide", "manual", "markdown", "markup"], 2), - }, - { - id: "security", - label: "Security", - family: "security", - threshold: 3, - topics: terms(["security", "auth", "oauth", "crypto", "vulnerability", "secrets"], 2), - keywords: terms(["security", "authentication", "authorization", "oauth", "crypto"], 2), - }, - { - id: "learning-resource", - label: "Learning Resource", - family: "learning", - threshold: 3, - topics: terms( - [ - "awesome", - "tutorial", - "learning", - "examples", - "course", - "book", - "education", - "learn-to-code", - "training-materials", - "programming", - ], - 2, - ), - keywords: terms( - [ - "awesome", - "tutorial", - "learn", - "learning", - "resources", - "book", - "guide", - "examples", - "courses", - ], - 2, - ), - }, - { - id: "library", - label: "Library", - family: "library", - threshold: 3, - topics: terms(["library", "package", "sdk", "component", "plugin"], 2), - keywords: terms(["library", "package", "sdk", "framework", "component"], 2), - }, - { - id: "application", - label: "Application", - family: "application", - threshold: 3, - topics: terms(["app", "application", "desktop-app", "webapp"], 2), - keywords: terms(["application", "app", "client", "dashboard"], 2), - }, -]; +import { + type AnalysisPlan, + analysisPlan, + analysisVersion, + categoryRulesVersion, + scoreVersion, +} from "./analysis-config"; +import { defaultCategoryRules } from "./category-config"; +import { + dayMs, + defaultStaleAgeScore, + freshnessScoreConfig, + insightLabelConfig, + maintenancePenaltyConfig, + popularityScoreConfig, + scoreAgeThresholds, + scoreImpactThresholds, + scoreWeights, + topicDensityConfig, + yearDays, +} from "./scoring-config"; + +export type { AnalysisPlan }; +export { analysisPlan, analysisVersion, categoryRulesVersion, defaultCategoryRules, scoreVersion }; export function analyzeRepository( repository: ForageRepository, @@ -338,43 +124,47 @@ export function scoreRepository(repository: ForageRepository, now = new Date()): metadata_quality: scoreMetadataQuality(repository), topic_density: scoreTopicDensity(repository), overall: combineScores([ - [activity, 0.3], - [popularity, 0.25], - [freshness, 0.2], - [maintenance, 0.25], + [activity, scoreWeights.activity], + [popularity, scoreWeights.popularity], + [freshness, scoreWeights.freshness], + [maintenance, scoreWeights.maintenance], ]), }; } function scoreActivity(repository: ForageRepository, now: Date): ScoreValue { const pushedDays = daysSince(repository.pushed_at ?? repository.updated_at, now); - const value = ageScore(pushedDays, [ - [30, 100], - [90, 88], - [180, 74], - [365, 58], - [730, 34], - ]); + const value = ageScore(pushedDays, scoreAgeThresholds.activityPushedDays); return scoreValue(value, [ explanation( "activity", pushedDays === null ? "No push timestamp available." : `Last pushed ${formatDays(pushedDays)} ago.`, - value >= 58 ? "positive" : "negative", + value >= scoreImpactThresholds.activityPositive ? "positive" : "negative", ), ]); } function scorePopularity(repository: ForageRepository): ScoreValue { - const starScore = clamp(Math.round((Math.log10(repository.stars + 1) / 5) * 100), 0, 100); - const forkScore = clamp(Math.round((Math.log10(repository.forks + 1) / 4) * 100), 0, 100); - const value = Math.round(starScore * 0.75 + forkScore * 0.25); + const starScore = clamp( + Math.round((Math.log10(repository.stars + 1) / popularityScoreConfig.starLogCap) * 100), + 0, + 100, + ); + const forkScore = clamp( + Math.round((Math.log10(repository.forks + 1) / popularityScoreConfig.forkLogCap) * 100), + 0, + 100, + ); + const value = Math.round( + starScore * popularityScoreConfig.starWeight + forkScore * popularityScoreConfig.forkWeight, + ); return scoreValue(value, [ explanation( "popularity", `${repository.stars.toLocaleString()} stars and ${repository.forks.toLocaleString()} forks with logarithmic caps.`, - value >= 50 ? "positive" : "neutral", + value >= scoreImpactThresholds.popularityPositive ? "positive" : "neutral", ), ]); } @@ -382,61 +172,49 @@ function scorePopularity(repository: ForageRepository): ScoreValue { function scoreFreshness(repository: ForageRepository, now: Date): ScoreValue { const createdDays = daysSince(repository.created_at, now); const updatedDays = daysSince(repository.updated_at, now); - const createdScore = ageScore(createdDays, [ - [180, 88], - [365, 78], - [730, 62], - [1460, 46], - [2920, 30], - ]); - const updatedScore = ageScore(updatedDays, [ - [90, 100], - [180, 84], - [365, 68], - [730, 48], - [1460, 28], - ]); - const value = Math.round(createdScore * 0.35 + updatedScore * 0.65); + const createdScore = ageScore(createdDays, scoreAgeThresholds.freshnessCreatedDays); + const updatedScore = ageScore(updatedDays, scoreAgeThresholds.freshnessUpdatedDays); + const value = Math.round( + createdScore * freshnessScoreConfig.createdWeight + + updatedScore * freshnessScoreConfig.updatedWeight, + ); return scoreValue(value, [ explanation( "freshness", updatedDays === null ? "No update timestamp available." : `Last updated ${formatDays(updatedDays)} ago.`, - value >= 55 ? "positive" : "neutral", + value >= scoreImpactThresholds.freshnessPositive ? "positive" : "neutral", ), ]); } function scoreMaintenance(repository: ForageRepository, now: Date): ScoreValue { const updatedDays = daysSince(repository.updated_at, now); - let value = ageScore(updatedDays, [ - [90, 90], - [180, 78], - [365, 64], - [730, 44], - [1460, 24], - ]); + let value = ageScore(updatedDays, scoreAgeThresholds.maintenanceUpdatedDays); const explanations: ScoreExplanation[] = [ explanation( "maintenance", updatedDays === null ? "No update timestamp available." : `Maintenance recency is ${formatDays(updatedDays)} old.`, - value >= 64 ? "positive" : "neutral", + value >= scoreImpactThresholds.maintenancePositive ? "positive" : "neutral", ), ]; if (repository.archived) { - value -= 35; + value -= maintenancePenaltyConfig.archived; explanations.push(explanation("archived", "Repository is archived.", "negative")); } if (repository.disabled) { - value -= 45; + value -= maintenancePenaltyConfig.disabled; explanations.push(explanation("disabled", "Repository is disabled.", "negative")); } - if (repository.open_issues > 200 && repository.stars < 1000) { - value -= 10; + if ( + repository.open_issues > maintenancePenaltyConfig.highIssueLoadOpenIssues && + repository.stars < maintenancePenaltyConfig.highIssueLoadStars + ) { + value -= maintenancePenaltyConfig.highIssueLoad; explanations.push( explanation("issue-load", "High open issue count relative to popularity.", "negative"), ); @@ -464,7 +242,7 @@ function scoreMetadataQuality(repository: ForageRepository): ScoreValue { } function scoreTopicDensity(repository: ForageRepository): ScoreValue { - const value = clamp(repository.topics.length * 14, 0, 100); + const value = clamp(repository.topics.length * topicDensityConfig.perTopicPoints, 0, 100); return scoreValue(value, [ explanation( "topics", @@ -478,15 +256,22 @@ function buildLabels(repository: ForageRepository, scores: ScoreSet, now: Date): const labels: InsightLabel[] = []; const pushedDays = daysSince(repository.pushed_at ?? repository.updated_at, now); - if (scores.overall.value >= 80) { + if (scores.overall.value >= insightLabelConfig.worthRevisitingOverall) { labels.push(label("worth-revisiting", "Worth Revisiting", ["High foundational score."])); } - if (repository.stars >= 1000 && scores.activity.value >= 70) { + if ( + repository.stars >= insightLabelConfig.stillActiveStars && + scores.activity.value >= insightLabelConfig.stillActiveActivity + ) { labels.push( label("still-active", "Still Active", ["Popular repository with recent activity."]), ); } - if (repository.stars >= 1000 && pushedDays !== null && pushedDays > 730) { + if ( + repository.stars >= insightLabelConfig.deadButInterestingStars && + pushedDays !== null && + pushedDays > insightLabelConfig.deadButInterestingPushedDays + ) { labels.push( label("dead-but-interesting", "Dead But Interesting", [ "Popular repository with stale activity.", @@ -494,9 +279,9 @@ function buildLabels(repository: ForageRepository, scores: ScoreSet, now: Date): ); } if ( - repository.stars < 250 && + repository.stars < insightLabelConfig.smallPolishedStars && scores.metadata_quality?.value && - scores.metadata_quality.value >= 80 + scores.metadata_quality.value >= insightLabelConfig.smallPolishedMetadataQuality ) { labels.push( label("small-polished", "Small But Polished", [ @@ -521,20 +306,6 @@ function combineScores(weightedScores: Array<[ScoreValue, number]>): ScoreValue ]); } -function languageRule(id: string, label: string, language: string): CategoryRule { - return { - id, - label, - family: "language", - threshold: 2, - languages: terms([language], 2), - }; -} - -function terms(values: string[], weight: number) { - return values.map((value) => ({ value, weight })); -} - function scoreValue(value: number, explanations: ScoreExplanation[]): ScoreValue { return { value: clamp(value, 0, 100), @@ -566,7 +337,7 @@ function ageScore(days: number | null, thresholds: Array<[number, number]>) { if (days <= maxDays) return value; } - return 12; + return defaultStaleAgeScore; } function daysSince(value: string | null, now: Date) { @@ -575,14 +346,14 @@ function daysSince(value: string | null, now: Date) { const timestamp = new Date(value).getTime(); if (Number.isNaN(timestamp)) return null; - return Math.max(0, Math.floor((now.getTime() - timestamp) / 86_400_000)); + return Math.max(0, Math.floor((now.getTime() - timestamp) / dayMs)); } function formatDays(days: number) { if (days === 0) return "today"; if (days === 1) return "1 day"; - if (days < 365) return `${days} days`; - return `${Math.floor(days / 365)} year(s)`; + if (days < yearDays) return `${days} days`; + return `${Math.floor(days / yearDays)} year(s)`; } function normalizeText(value: string) { diff --git a/packages/analysis/src/scoring-config.ts b/packages/analysis/src/scoring-config.ts new file mode 100644 index 0000000..d3c3672 --- /dev/null +++ b/packages/analysis/src/scoring-config.ts @@ -0,0 +1,83 @@ +export const scoreWeights = { + activity: 0.3, + popularity: 0.25, + freshness: 0.2, + maintenance: 0.25, +} as const; + +export const scoreAgeThresholds = { + activityPushedDays: [ + [30, 100], + [90, 88], + [180, 74], + [365, 58], + [730, 34], + ], + freshnessCreatedDays: [ + [180, 88], + [365, 78], + [730, 62], + [1460, 46], + [2920, 30], + ], + freshnessUpdatedDays: [ + [90, 100], + [180, 84], + [365, 68], + [730, 48], + [1460, 28], + ], + maintenanceUpdatedDays: [ + [90, 90], + [180, 78], + [365, 64], + [730, 44], + [1460, 24], + ], +} satisfies Record>; + +export const defaultStaleAgeScore = 12; + +export const popularityScoreConfig = { + starLogCap: 5, + forkLogCap: 4, + starWeight: 0.75, + forkWeight: 0.25, +} as const; + +export const freshnessScoreConfig = { + createdWeight: 0.35, + updatedWeight: 0.65, +} as const; + +export const maintenancePenaltyConfig = { + archived: 35, + disabled: 45, + highIssueLoad: 10, + highIssueLoadOpenIssues: 200, + highIssueLoadStars: 1000, +} as const; + +export const scoreImpactThresholds = { + activityPositive: 58, + popularityPositive: 50, + freshnessPositive: 55, + maintenancePositive: 64, +} as const; + +export const topicDensityConfig = { + perTopicPoints: 14, +} as const; + +export const insightLabelConfig = { + worthRevisitingOverall: 80, + stillActiveStars: 1000, + stillActiveActivity: 70, + deadButInterestingStars: 1000, + deadButInterestingPushedDays: 730, + smallPolishedStars: 250, + smallPolishedMetadataQuality: 80, +} as const; + +export const dayMs = 86_400_000; +export const yearDays = 365;