From 9e85e0a21eae2b4b83c060cfb9b53fbc7ca57cdb Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Sun, 12 Jul 2026 17:20:26 -0700 Subject: [PATCH 1/2] feat(miner): persist resolved policy verdicts across discover runs Add a local cache (policy-verdict-cache.js) keyed by repo + the ETag of whichever doc (AI-USAGE.md or CONTRIBUTING.md) decided the verdict. fetchRepoDoc now surfaces the ETag it used alongside doc content, so resolveRepoAiPolicy can skip resolveAiPolicyVerdict outright once a same-run conditional-GET (#4842) confirms the deciding doc's ETag hasn't moved -- reusing the prior verdict is exactly as correct as recomputing it, since both only run against the identical doc text. A cache miss (cold cache, changed ETag, or a different doc becoming decisive) always falls through to a fresh resolution and re-caches the result. Same fail-open discipline as the doc cache: any cache read or write failure degrades to "resolve fresh" rather than affecting discovery, and discover-cli.js opens the store in its own try/catch so a corrupt/unwritable cache DB can never abort a run. Closes #4843 --- packages/gittensory-miner/README.md | 16 +- .../gittensory-miner/docs/env-reference.md | 1 + .../gittensory-miner/lib/discover-cli.d.ts | 2 + packages/gittensory-miner/lib/discover-cli.js | 17 +- .../lib/opportunity-fanout.d.ts | 5 +- .../lib/opportunity-fanout.js | 87 +++++- .../lib/policy-verdict-cache.d.ts | 33 +++ .../lib/policy-verdict-cache.js | 101 +++++++ packages/gittensory-miner/package.json | 2 +- test/unit/miner-discover-cli.test.ts | 89 ++++++ test/unit/miner-local-store-readme.test.ts | 1 + test/unit/miner-policy-verdict-cache.test.ts | 138 +++++++++ ...tunity-fanout-policy-verdict-cache.test.ts | 273 ++++++++++++++++++ 13 files changed, 742 insertions(+), 23 deletions(-) create mode 100644 packages/gittensory-miner/lib/policy-verdict-cache.d.ts create mode 100644 packages/gittensory-miner/lib/policy-verdict-cache.js create mode 100644 test/unit/miner-policy-verdict-cache.test.ts create mode 100644 test/unit/opportunity-fanout-policy-verdict-cache.test.ts diff --git a/packages/gittensory-miner/README.md b/packages/gittensory-miner/README.md index c408ee4050..6921c983b2 100644 --- a/packages/gittensory-miner/README.md +++ b/packages/gittensory-miner/README.md @@ -107,12 +107,16 @@ See [`docs/env-reference.md`](docs/env-reference.md) for the full `GITTENSORY_MI | Worktree allocator | `worktree-allocator.sqlite3` | `worktree_slots` | `worktree-allocator.js` | `GITTENSORY_MINER_WORKTREE_ALLOCATOR_DB` | | Orb export | `orb-export.sqlite3` | `orb_export_meta` | `orb-export.js` | `GITTENSORY_MINER_ORB_EXPORT_DB` | | Policy-doc cache | `policy-doc-cache.sqlite3` | `policy_doc_cache` | `policy-doc-cache.js` | `GITTENSORY_MINER_POLICY_DOC_CACHE_DB` | - -The policy-doc cache is the only store above that holds no miner state of its own: it caches the last-known ETag + -body of each target repo's fetched policy docs (AI-USAGE.md/CONTRIBUTING.md) so a repeated `discover` revalidates -them with a conditional GET (`If-None-Match`) instead of re-downloading static content, spending no extra -rate-limit budget when GitHub answers `304 Not Modified`. It is pure optimization — deleting the file only forces -the next run to refetch in full (#4842). +| Policy-verdict cache | `policy-verdict-cache.sqlite3` | `policy_verdict_cache` | `policy-verdict-cache.js` | `GITTENSORY_MINER_POLICY_VERDICT_CACHE_DB` | + +The policy-doc and policy-verdict caches are the only stores above that hold no miner state of their own — both are +pure optimization, and deleting either file only forces the next run to redo the work it would have skipped. The +policy-doc cache caches the last-known ETag + body of each target repo's fetched policy docs +(AI-USAGE.md/CONTRIBUTING.md) so a repeated `discover` revalidates them with a conditional GET (`If-None-Match`) +instead of re-downloading static content, spending no extra rate-limit budget when GitHub answers +`304 Not Modified` (#4842). The policy-verdict cache goes one step further: once a repo's deciding doc's ETag is +confirmed unchanged, it reuses the already-resolved AI-usage-policy verdict instead of re-resolving it from the +(identical) doc text (#4843). Every store resolves its file the same way: the store-specific env var above, else `GITTENSORY_MINER_CONFIG_DIR`, else `XDG_CONFIG_HOME` (falling back to `~/.config`), joined with `gittensory-miner/`. Every store also opens diff --git a/packages/gittensory-miner/docs/env-reference.md b/packages/gittensory-miner/docs/env-reference.md index bf24684b54..d95a42ff50 100644 --- a/packages/gittensory-miner/docs/env-reference.md +++ b/packages/gittensory-miner/docs/env-reference.md @@ -17,6 +17,7 @@ Generated by `npm run miner:env-reference`. Do not edit manually. | `GITTENSORY_MINER_ORB_EXPORT_DB` | `lib/orb-export.js` | `""` | | `GITTENSORY_MINER_PLAN_STORE_DB` | `lib/plan-store.js` | `""` | | `GITTENSORY_MINER_POLICY_DOC_CACHE_DB` | `lib/policy-doc-cache.js` | (none) | +| `GITTENSORY_MINER_POLICY_VERDICT_CACHE_DB` | `lib/policy-verdict-cache.js` | (none) | | `GITTENSORY_MINER_PORTFOLIO_QUEUE_DB` | `lib/portfolio-queue.js` | (none) | | `GITTENSORY_MINER_PREDICTION_LEDGER_DB` | `lib/prediction-ledger.js` | `""` | | `GITTENSORY_MINER_REPLAY_SNAPSHOT_DB` | `lib/replay-snapshot.js` | (none) | diff --git a/packages/gittensory-miner/lib/discover-cli.d.ts b/packages/gittensory-miner/lib/discover-cli.d.ts index 4a7a632ef2..238634dcef 100644 --- a/packages/gittensory-miner/lib/discover-cli.d.ts +++ b/packages/gittensory-miner/lib/discover-cli.d.ts @@ -11,6 +11,7 @@ import type { RankedCandidateSummary, } from "./opportunity-ranker.js"; import type { PolicyDocCacheStore } from "./policy-doc-cache.js"; +import type { PolicyVerdictCacheStore } from "./policy-verdict-cache.js"; import type { EnqueueRankedDiscoverySummary } from "./portfolio-discovery.js"; import type { PortfolioQueueStore } from "./portfolio-queue.js"; @@ -62,6 +63,7 @@ export type RunDiscoverOptions = { goalSpecContentByRepo?: RankCandidateIssuesOptions["goalSpecContentByRepo"]; initPortfolioQueue?: () => PortfolioQueueStore; initPolicyDocCache?: () => PolicyDocCacheStore; + initPolicyVerdictCache?: () => PolicyVerdictCacheStore; fetchCandidateIssuesWithSummary?: ( targets: FanoutTarget[], githubToken: string, diff --git a/packages/gittensory-miner/lib/discover-cli.js b/packages/gittensory-miner/lib/discover-cli.js index f6ca6a0dcf..94444f9824 100644 --- a/packages/gittensory-miner/lib/discover-cli.js +++ b/packages/gittensory-miner/lib/discover-cli.js @@ -7,6 +7,7 @@ import { } from "./opportunity-fanout.js"; import { rankCandidateIssuesWithSummary } from "./opportunity-ranker.js"; import { initPolicyDocCacheStore } from "./policy-doc-cache.js"; +import { initPolicyVerdictCacheStore } from "./policy-verdict-cache.js"; import { enqueueRankedDiscovery } from "./portfolio-discovery.js"; import { initPortfolioQueueStore } from "./portfolio-queue.js"; @@ -173,7 +174,20 @@ export async function runDiscover(args, options = {}) { policyDocCache = null; ownsPolicyDocCache = false; } - const fanOutOptions = { apiBaseUrl, forge: options.forge, policyDocCache }; + + // Persisted cache of resolved policy verdicts (#4843), same "own try/catch, degrade to null" discipline as the + // doc cache above and for the same reason: purely a performance optimization the feature is inert without, so a + // corrupt/unwritable cache DB must never abort a run. + let policyVerdictCache = null; + let ownsPolicyVerdictCache = false; + try { + ownsPolicyVerdictCache = options.initPolicyVerdictCache === undefined; + policyVerdictCache = (options.initPolicyVerdictCache ?? initPolicyVerdictCacheStore)(); + } catch { + policyVerdictCache = null; + ownsPolicyVerdictCache = false; + } + const fanOutOptions = { apiBaseUrl, forge: options.forge, policyDocCache, policyVerdictCache }; try { const fanOut = @@ -213,5 +227,6 @@ export async function runDiscover(args, options = {}) { } finally { if (ownsPortfolioQueue) portfolioQueue.close(); if (ownsPolicyDocCache && policyDocCache) policyDocCache.close(); + if (ownsPolicyVerdictCache && policyVerdictCache) policyVerdictCache.close(); } } diff --git a/packages/gittensory-miner/lib/opportunity-fanout.d.ts b/packages/gittensory-miner/lib/opportunity-fanout.d.ts index 1a80d69588..0e67a1abc3 100644 --- a/packages/gittensory-miner/lib/opportunity-fanout.d.ts +++ b/packages/gittensory-miner/lib/opportunity-fanout.d.ts @@ -1,5 +1,6 @@ import type { ForgeConfig } from "./forge-config.js"; import type { PolicyDocCache } from "./policy-doc-cache.js"; +import type { PolicyVerdictCache } from "./policy-verdict-cache.js"; export type FanoutTarget = { owner: string; @@ -9,7 +10,8 @@ export type FanoutTarget = { /** Options shared by every fan-out entry point. `apiBaseUrl` is the legacy top-level forge-host override (it still * wins over `forge.apiBaseUrl`); `forge` (#4784) carries the rest of the per-tenant forge knobs. `policyDocCache`, * when supplied, lets discovery revalidate each repo's policy docs with a conditional GET instead of a full - * refetch (#4842). */ + * refetch (#4842). `policyVerdictCache`, when supplied, lets discovery reuse an already-resolved verdict once its + * deciding doc's ETag is confirmed unchanged, instead of re-resolving it (#4843). */ export type FanoutOptions = { apiBaseUrl?: string; forge?: Partial; @@ -20,6 +22,7 @@ export type FanoutOptions = { maxPages?: number; sleepFn?: (ms: number) => Promise; policyDocCache?: PolicyDocCache | null; + policyVerdictCache?: PolicyVerdictCache | null; }; export type RawCandidateIssue = { diff --git a/packages/gittensory-miner/lib/opportunity-fanout.js b/packages/gittensory-miner/lib/opportunity-fanout.js index 3804829969..c562efaada 100644 --- a/packages/gittensory-miner/lib/opportunity-fanout.js +++ b/packages/gittensory-miner/lib/opportunity-fanout.js @@ -159,13 +159,16 @@ function readCachedPolicyDoc(cache, url) { } } +function readEtagHeader(response) { + const etag = response.headers.get("etag"); + return typeof etag === "string" && etag.trim() ? etag : null; +} + // Persist the fresh ETag + body so the NEXT discover run can revalidate instead of re-downloading. Only a real // ETag paired with decoded content is stored, and a write that throws must never fail discovery (same stale-safe // rule) — it degrades to "not cached", so the next run simply refetches in full. -function writeCachedPolicyDoc(cache, url, response, content) { - if (!cache || content === null) return; - const etag = response.headers.get("etag"); - if (typeof etag !== "string" || !etag.trim()) return; +function writeCachedPolicyDoc(cache, url, etag, content) { + if (!cache || content === null || etag === null) return; try { cache.put(url, etag, content); } catch { @@ -173,6 +176,30 @@ function writeCachedPolicyDoc(cache, url, response, content) { } } +// Read a repo's previously-resolved verdict for the SAME decisive doc + ETag (#4843). A cache that is absent, or +// whose read throws (corrupt/locked file), is treated as a plain miss — the caller resolves the verdict fresh, +// per the same "never risk a stale policy" rule as the doc cache above. +function readCachedPolicyVerdict(cache, repoFullName) { + if (!cache) return null; + try { + return cache.get(repoFullName); + } catch { + return null; + } +} + +// Persist the freshly-resolved verdict against the ETag of the doc that decided it, so the next run can reuse it +// outright once that ETag is confirmed unchanged. Only ever called with a real ETag; a write that throws must +// never fail discovery — it degrades to "not cached", so the next run just resolves the verdict again. +function writeCachedPolicyVerdict(cache, repoFullName, decisiveDoc, etag, verdict) { + if (!cache || etag === null) return; + try { + cache.put(repoFullName, decisiveDoc, etag, verdict); + } catch { + // Leave this repo uncached; the next run just resolves the verdict fresh again. + } +} + async function fetchRepoDoc(target, path, githubToken, options, summary, warnings) { const url = apiUrl( options.apiBaseUrl, @@ -184,33 +211,56 @@ async function fetchRepoDoc(target, path, githubToken, options, summary, warning const { response, payload } = await githubGetJson(url, githubToken, summary, options, conditionalHeaders); // A 304 only ever follows the If-None-Match we send above, which we only send when `cached` exists — so the // cached body is the GitHub-confirmed current content, served with no extra rate-limit spend. - if (response.status === 304) return cached.content; - if (response.status === 404) return null; + if (response.status === 304) return { content: cached.content, etag: cached.etag }; + if (response.status === 404) return { content: null, etag: null }; if (!response.ok) { warnings.push(warning(target, `policy:${path}`, `GitHub returned ${response.status}`)); - return null; + return { content: null, etag: null }; } const content = decodeContentPayload(payload); - writeCachedPolicyDoc(options.policyDocCache, url, response, content); - return content; + const etag = readEtagHeader(response); + writeCachedPolicyDoc(options.policyDocCache, url, etag, content); + return { content, etag }; } catch (error) { warnings.push( warning(target, `policy:${path}`, error instanceof Error ? error.message : "policy fetch failed"), ); - return null; + return { content: null, etag: null }; } } +// Resolve a repo's AI-usage-policy verdict, reusing a cached one when the deciding doc's ETag hasn't moved since +// it was last resolved (#4843). Only ever consulted with an ETag that a same-run conditional-GET just confirmed +// is current, so a cache hit is exactly as correct as recomputing — it just skips the (cheap, but not free) parse. +function resolveOrCacheVerdict(cache, repoFullName, decisiveDoc, etag, computeVerdict) { + if (etag !== null) { + const cached = readCachedPolicyVerdict(cache, repoFullName); + if (cached && cached.decisiveDoc === decisiveDoc && cached.etag === etag) return cached.verdict; + } + const verdict = computeVerdict(); + writeCachedPolicyVerdict(cache, repoFullName, decisiveDoc, etag, verdict); + return verdict; +} + async function resolveRepoAiPolicy(target, githubToken, options, summary, warnings) { - const aiUsage = await fetchRepoDoc(target, "AI-USAGE.md", githubToken, options, summary, warnings); + const { content: aiUsage, etag: aiUsageEtag } = await fetchRepoDoc( + target, + "AI-USAGE.md", + githubToken, + options, + summary, + warnings, + ); // Short-circuit only on AI-USAGE.md that has real content. A present-but-blank AI-USAGE.md must still fall // through to CONTRIBUTING.md — otherwise a stub AI-USAGE.md silently fails open and swallows a ban declared in // CONTRIBUTING.md (the exact case resolveAiPolicyVerdict was fixed to handle in #2900, which can only fire if // both docs reach it). if (aiUsage !== null && aiUsage.trim().length > 0) { - return resolveAiPolicyVerdict({ aiUsage, contributing: null }); + return resolveOrCacheVerdict(options.policyVerdictCache, target.repoFullName, "AI-USAGE.md", aiUsageEtag, () => + resolveAiPolicyVerdict({ aiUsage, contributing: null }), + ); } - const contributing = await fetchRepoDoc( + const { content: contributing, etag: contributingEtag } = await fetchRepoDoc( target, "CONTRIBUTING.md", githubToken, @@ -218,7 +268,13 @@ async function resolveRepoAiPolicy(target, githubToken, options, summary, warnin summary, warnings, ); - return resolveAiPolicyVerdict({ aiUsage: null, contributing }); + return resolveOrCacheVerdict( + options.policyVerdictCache, + target.repoFullName, + "CONTRIBUTING.md", + contributingEtag, + () => resolveAiPolicyVerdict({ aiUsage: null, contributing }), + ); } function labelNames(labels) { @@ -442,6 +498,9 @@ function normalizeOptions(options = {}) { // Optional local ETag cache for policy-doc revalidation (#4842). Absent (null) => every policy doc is fetched // in full, exactly as before; discover-cli.js supplies the real on-disk store for a live run. policyDocCache: options.policyDocCache ?? null, + // Optional local cache of resolved policy verdicts (#4843). Absent (null) => every verdict is resolved fresh, + // exactly as before; discover-cli.js supplies the real on-disk store for a live run. + policyVerdictCache: options.policyVerdictCache ?? null, }; } diff --git a/packages/gittensory-miner/lib/policy-verdict-cache.d.ts b/packages/gittensory-miner/lib/policy-verdict-cache.d.ts new file mode 100644 index 0000000000..e1125d7728 --- /dev/null +++ b/packages/gittensory-miner/lib/policy-verdict-cache.d.ts @@ -0,0 +1,33 @@ +import type { AiPolicyVerdict } from "@jsonbored/gittensory-engine"; + +export type PolicyVerdictDecisiveDoc = "AI-USAGE.md" | "CONTRIBUTING.md"; + +export type PolicyVerdictCacheEntry = { + decisiveDoc: PolicyVerdictDecisiveDoc; + etag: string; + verdict: AiPolicyVerdict; +}; + +export type PolicyVerdictCacheWrite = PolicyVerdictCacheEntry & { + repoFullName: string; + updatedAt: string; +}; + +export type PolicyVerdictCacheStore = { + dbPath: string; + get(repoFullName: string): PolicyVerdictCacheEntry | null; + put( + repoFullName: string, + decisiveDoc: PolicyVerdictDecisiveDoc, + etag: string, + verdict: AiPolicyVerdict, + ): PolicyVerdictCacheWrite; + close(): void; +}; + +/** The read/write surface opportunity-fanout.js needs to inject a cache without depending on the SQLite store. */ +export type PolicyVerdictCache = Pick; + +export function resolvePolicyVerdictCacheDbPath(env?: Record): string; + +export function initPolicyVerdictCacheStore(dbPath?: string): PolicyVerdictCacheStore; diff --git a/packages/gittensory-miner/lib/policy-verdict-cache.js b/packages/gittensory-miner/lib/policy-verdict-cache.js new file mode 100644 index 0000000000..e152eac1a9 --- /dev/null +++ b/packages/gittensory-miner/lib/policy-verdict-cache.js @@ -0,0 +1,101 @@ +import { normalizeLocalStoreDbPath, openLocalStoreDb, resolveLocalStoreDbPath } from "./local-store.js"; +import { applySchemaMigrations } from "./schema-version.js"; + +// Local cache of resolved AI-usage-policy verdicts (#4843). Even with #4842's conditional-GET doc cache, the small +// but non-zero cost of resolving `resolveAiPolicyVerdict` from raw doc text was still paid on every discover run. +// This stores the verdict itself, keyed by repo + the ETag of whichever doc actually decided it, so a repeat run +// against an unchanged repo reuses the prior verdict outright once opportunity-fanout.js's same-run conditional-GET +// confirms that doc's ETag hasn't moved -- never served blindly, exactly the same "cheaper, never less correct" +// discipline as policy-doc-cache.js. 100% local/client-side, same as every other store this package owns via +// local-store.js: the file lives only on this machine and is never uploaded, synced, or phoned home with. + +const defaultDbFileName = "policy-verdict-cache.sqlite3"; +const DECISIVE_DOCS = new Set(["AI-USAGE.md", "CONTRIBUTING.md"]); + +export function resolvePolicyVerdictCacheDbPath(env = process.env) { + return resolveLocalStoreDbPath(defaultDbFileName, "GITTENSORY_MINER_POLICY_VERDICT_CACHE_DB", env); +} + +function normalizeDbPath(dbPath) { + return normalizeLocalStoreDbPath(dbPath, resolvePolicyVerdictCacheDbPath(), "invalid_policy_verdict_cache_db_path"); +} + +function normalizeRepoFullName(repoFullName) { + if (typeof repoFullName !== "string") throw new Error("invalid_policy_verdict_repo_full_name"); + const trimmed = repoFullName.trim(); + if (!trimmed) throw new Error("invalid_policy_verdict_repo_full_name"); + return trimmed; +} + +function normalizeDecisiveDoc(decisiveDoc) { + if (!DECISIVE_DOCS.has(decisiveDoc)) throw new Error("invalid_policy_verdict_decisive_doc"); + return decisiveDoc; +} + +function normalizeEtag(etag) { + if (typeof etag !== "string" || !etag.trim()) throw new Error("invalid_policy_verdict_etag"); + return etag; +} + +function serializeVerdict(verdict) { + if (!verdict || typeof verdict !== "object" || Array.isArray(verdict)) { + throw new Error("invalid_policy_verdict"); + } + return JSON.stringify(verdict); +} + +/** + * Opens the 100% local/client-side miner policy-verdict cache. The database only lives on this machine; this + * module never uploads, syncs, or phones home with its contents. (#4843) + */ +export function initPolicyVerdictCacheStore(dbPath = resolvePolicyVerdictCacheDbPath()) { + const resolvedPath = normalizeDbPath(dbPath); + const db = openLocalStoreDb(resolvedPath); + db.exec(` + CREATE TABLE IF NOT EXISTS policy_verdict_cache ( + repo_full_name TEXT PRIMARY KEY, + decisive_doc TEXT NOT NULL, + etag TEXT NOT NULL, + verdict TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + `); + // Schema-version convention (#4832): stamp the baseline and run any post-baseline migrations (none yet). + applySchemaMigrations(db, []); + + const getStatement = db.prepare( + "SELECT decisive_doc, etag, verdict FROM policy_verdict_cache WHERE repo_full_name = ?", + ); + const putStatement = db.prepare(` + INSERT INTO policy_verdict_cache (repo_full_name, decisive_doc, etag, verdict, updated_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(repo_full_name) DO UPDATE SET + decisive_doc = excluded.decisive_doc, + etag = excluded.etag, + verdict = excluded.verdict, + updated_at = excluded.updated_at + `); + + return { + dbPath: resolvedPath, + /** The last-known `{ decisiveDoc, etag, verdict }` for a repo, or null when it has never been cached. */ + get(repoFullName) { + const row = getStatement.get(normalizeRepoFullName(repoFullName)); + if (!row) return null; + return { decisiveDoc: row.decisive_doc, etag: row.etag, verdict: JSON.parse(row.verdict) }; + }, + /** Record the resolved verdict against the ETag of the doc that decided it, so the next run can reuse it. */ + put(repoFullName, decisiveDoc, etag, verdict) { + const normalizedRepoFullName = normalizeRepoFullName(repoFullName); + const normalizedDecisiveDoc = normalizeDecisiveDoc(decisiveDoc); + const normalizedEtag = normalizeEtag(etag); + const serializedVerdict = serializeVerdict(verdict); + const updatedAt = new Date().toISOString(); + putStatement.run(normalizedRepoFullName, normalizedDecisiveDoc, normalizedEtag, serializedVerdict, updatedAt); + return { repoFullName: normalizedRepoFullName, decisiveDoc: normalizedDecisiveDoc, etag: normalizedEtag, verdict, updatedAt }; + }, + close() { + db.close(); + }, + }; +} diff --git a/packages/gittensory-miner/package.json b/packages/gittensory-miner/package.json index e303439ad4..8281d9d470 100644 --- a/packages/gittensory-miner/package.json +++ b/packages/gittensory-miner/package.json @@ -33,7 +33,7 @@ "expected-engine.version" ], "scripts": { - "build": "node --check bin/gittensory-miner.js && node --check bin/gittensory-miner-mcp.js && node --check lib/ams-policy.js && node --check lib/attempt-cli.js && node --check lib/attempt-input-builder.js && node --check lib/attempt-log.js && node --check lib/attempt-runner.js && node --check lib/attempt-worktree.js && node --check lib/calibration-run.js && node --check lib/calibration-types.js && node --check lib/calibration.js && node --check lib/ci-poller.js && node --check lib/claim-adjudication.js && node --check lib/claim-conflict-resolver.js && node --check lib/claim-ledger-cli.js && node --check lib/claim-ledger-expiry.js && node --check lib/claim-ledger.js && node --check lib/cli.js && node --check lib/coding-agent-construction.js && node --check lib/coding-agent-house-rules.js && node --check lib/coding-task-spec.js && node --check lib/deny-check.js && node --check lib/deny-hook-synthesis.js && node --check lib/deny-hooks.js && node --check lib/deployment-docs-audit.js && node --check lib/discover-cli.js && node --check lib/event-ledger-cli.js && node --check lib/event-ledger.js && node --check lib/execute-local-write.js && node --check lib/feasibility-cli.js && node --check lib/governor-action-mode.js && node --check lib/governor-chokepoint-persisted.js && node --check lib/governor-chokepoint.js && node --check lib/governor-kill-switch.js && node --check lib/governor-ledger-cli.js && node --check lib/governor-ledger.js && node --check lib/governor-open-pr.js && node --check lib/governor-run-halt.js && node --check lib/governor-state.js && node --check lib/governor-write-rate-limit.js && node --check lib/harness-submission-trigger.js && node --check lib/laptop-init.js && node --check lib/live-issue-snapshot.js && node --check lib/local-store.js && node --check lib/loop-cli.js && node --check lib/loop-closure.js && node --check lib/loop-reentry.js && node --check lib/manage-poll.js && node --check lib/manage-status.js && node --check lib/metrics-cli.js && node --check lib/miner-goal-spec.js && node --check lib/opportunity-fanout.js && node --check lib/opportunity-ranker.js && node --check lib/orb-export.js && node --check lib/plan-store-cli.js && node --check lib/plan-store.js && node --check lib/policy-doc-cache.js && node --check lib/portfolio-dashboard.js && node --check lib/portfolio-discovery.js && node --check lib/portfolio-queue-cli.js && node --check lib/portfolio-queue-manager.js && node --check lib/portfolio-queue.js && node --check lib/portfolio-queue-expiry.js && node --check lib/pr-disposition-poller.js && node --check lib/pr-number-parse.js && node --check lib/pr-outcome.js && node --check lib/prediction-ledger.js && node --check lib/pretooluse-hook.js && node --check lib/rejection-signal.js && node --check lib/rejection-state-machine.js && node --check lib/rejection-templates.js && node --check lib/replay-objective-anchor.js && node --check lib/replay-snapshot.js && node --check lib/replay-task-generation.js && node --check lib/repo-clone.js && node --check lib/run-state-cli.js && node --check lib/run-state.js && node --check lib/self-review-context.js && node --check lib/slop-assessment.js && node --check lib/stack-detection.js && node --check lib/status.js && node --check lib/submission-freshness-check.js && node --check lib/update-check.js && node --check lib/version.js && node --check lib/worktree-allocator.js" + "build": "node --check bin/gittensory-miner.js && node --check bin/gittensory-miner-mcp.js && node --check lib/ams-policy.js && node --check lib/attempt-cli.js && node --check lib/attempt-input-builder.js && node --check lib/attempt-log.js && node --check lib/attempt-runner.js && node --check lib/attempt-worktree.js && node --check lib/calibration-run.js && node --check lib/calibration-types.js && node --check lib/calibration.js && node --check lib/ci-poller.js && node --check lib/claim-adjudication.js && node --check lib/claim-conflict-resolver.js && node --check lib/claim-ledger-cli.js && node --check lib/claim-ledger-expiry.js && node --check lib/claim-ledger.js && node --check lib/cli.js && node --check lib/coding-agent-construction.js && node --check lib/coding-agent-house-rules.js && node --check lib/coding-task-spec.js && node --check lib/deny-check.js && node --check lib/deny-hook-synthesis.js && node --check lib/deny-hooks.js && node --check lib/deployment-docs-audit.js && node --check lib/discover-cli.js && node --check lib/event-ledger-cli.js && node --check lib/event-ledger.js && node --check lib/execute-local-write.js && node --check lib/feasibility-cli.js && node --check lib/governor-action-mode.js && node --check lib/governor-chokepoint-persisted.js && node --check lib/governor-chokepoint.js && node --check lib/governor-kill-switch.js && node --check lib/governor-ledger-cli.js && node --check lib/governor-ledger.js && node --check lib/governor-open-pr.js && node --check lib/governor-run-halt.js && node --check lib/governor-state.js && node --check lib/governor-write-rate-limit.js && node --check lib/harness-submission-trigger.js && node --check lib/laptop-init.js && node --check lib/live-issue-snapshot.js && node --check lib/local-store.js && node --check lib/loop-cli.js && node --check lib/loop-closure.js && node --check lib/loop-reentry.js && node --check lib/manage-poll.js && node --check lib/manage-status.js && node --check lib/metrics-cli.js && node --check lib/miner-goal-spec.js && node --check lib/opportunity-fanout.js && node --check lib/opportunity-ranker.js && node --check lib/orb-export.js && node --check lib/plan-store-cli.js && node --check lib/plan-store.js && node --check lib/policy-doc-cache.js && node --check lib/policy-verdict-cache.js && node --check lib/portfolio-dashboard.js && node --check lib/portfolio-discovery.js && node --check lib/portfolio-queue-cli.js && node --check lib/portfolio-queue-manager.js && node --check lib/portfolio-queue.js && node --check lib/portfolio-queue-expiry.js && node --check lib/pr-disposition-poller.js && node --check lib/pr-number-parse.js && node --check lib/pr-outcome.js && node --check lib/prediction-ledger.js && node --check lib/pretooluse-hook.js && node --check lib/rejection-signal.js && node --check lib/rejection-state-machine.js && node --check lib/rejection-templates.js && node --check lib/replay-objective-anchor.js && node --check lib/replay-snapshot.js && node --check lib/replay-task-generation.js && node --check lib/repo-clone.js && node --check lib/run-state-cli.js && node --check lib/run-state.js && node --check lib/self-review-context.js && node --check lib/slop-assessment.js && node --check lib/stack-detection.js && node --check lib/status.js && node --check lib/submission-freshness-check.js && node --check lib/update-check.js && node --check lib/version.js && node --check lib/worktree-allocator.js" }, "dependencies": { "@jsonbored/gittensory-engine": "*", diff --git a/test/unit/miner-discover-cli.test.ts b/test/unit/miner-discover-cli.test.ts index 8329fd049d..9b8427393f 100644 --- a/test/unit/miner-discover-cli.test.ts +++ b/test/unit/miner-discover-cli.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { initPolicyDocCacheStore } from "../../packages/gittensory-miner/lib/policy-doc-cache.js"; +import { initPolicyVerdictCacheStore } from "../../packages/gittensory-miner/lib/policy-verdict-cache.js"; import { closeDefaultPortfolioQueueStore, initPortfolioQueueStore, @@ -39,6 +40,15 @@ function tempPolicyDocCacheStore() { return store; } +// Same reasoning as tempPolicyDocCacheStore above, for the policy-verdict cache (#4843). +function tempPolicyVerdictCacheStore() { + const root = mkdtempSync(join(tmpdir(), "gittensory-miner-discover-cli-pvc-")); + roots.push(root); + const store = initPolicyVerdictCacheStore(join(root, "policy-verdict-cache.sqlite3")); + stores.push(store); + return store; +} + function fanOutIssue(overrides: Record = {}) { return { owner: "acme", @@ -301,6 +311,7 @@ describe("runDiscover (#4247)", () => { nowMs: NOW, initPortfolioQueue: () => portfolioQueue, initPolicyDocCache: () => tempPolicyDocCacheStore(), + initPolicyVerdictCache: () => tempPolicyVerdictCacheStore(), fetchCandidateIssuesWithSummary, searchCandidateIssuesWithSummary, }); @@ -342,6 +353,7 @@ describe("runDiscover (#4247)", () => { nowMs: NOW, initPortfolioQueue: () => portfolioQueue, initPolicyDocCache: () => tempPolicyDocCacheStore(), + initPolicyVerdictCache: () => tempPolicyVerdictCacheStore(), fetchCandidateIssuesWithSummary, searchCandidateIssuesWithSummary, }); @@ -366,6 +378,7 @@ describe("runDiscover (#4247)", () => { nowMs: NOW, initPortfolioQueue: () => portfolioQueue, initPolicyDocCache: () => tempPolicyDocCacheStore(), + initPolicyVerdictCache: () => tempPolicyVerdictCacheStore(), fetchCandidateIssuesWithSummary, }); @@ -398,6 +411,7 @@ describe("runDiscover (#4247)", () => { const exitCode = await runDiscover(["acme/widgets"], { initPortfolioQueue: () => portfolioQueue, initPolicyDocCache: () => tempPolicyDocCacheStore(), + initPolicyVerdictCache: () => tempPolicyVerdictCacheStore(), fetchCandidateIssuesWithSummary, }); @@ -424,6 +438,7 @@ describe("runDiscover (#4247)", () => { nowMs: NOW, fetchCandidateIssuesWithSummary, initPolicyDocCache: () => tempPolicyDocCacheStore(), + initPolicyVerdictCache: () => tempPolicyVerdictCacheStore(), }); expect(exitCode).toBe(0); @@ -457,6 +472,7 @@ describe("runDiscover (#4247)", () => { nowMs: NOW, initPortfolioQueue: () => portfolioQueue, initPolicyDocCache: () => tempPolicyDocCacheStore(), + initPolicyVerdictCache: () => tempPolicyVerdictCacheStore(), fetchCandidateIssuesWithSummary, }, ); @@ -490,6 +506,7 @@ describe("runDiscover (#4247)", () => { nowMs: NOW, initPortfolioQueue: () => portfolioQueue, initPolicyDocCache: () => tempPolicyDocCacheStore(), + initPolicyVerdictCache: () => tempPolicyVerdictCacheStore(), fetchCandidateIssuesWithSummary, forge: { tokenEnvVar: "FORGE_PAT" }, }); @@ -520,6 +537,7 @@ describe("runDiscover (#4247)", () => { nowMs: NOW, initPortfolioQueue: () => portfolioQueue, initPolicyDocCache: () => tempPolicyDocCacheStore(), + initPolicyVerdictCache: () => tempPolicyVerdictCacheStore(), fetchCandidateIssuesWithSummary, githubToken: "explicit-token", apiBaseUrl: "https://programmatic.example.com", @@ -565,6 +583,7 @@ describe("runDiscover (#4247)", () => { nowMs: NOW, initPortfolioQueue: () => portfolioQueue, initPolicyDocCache: () => tempPolicyDocCacheStore(), + initPolicyVerdictCache: () => tempPolicyVerdictCacheStore(), fetchCandidateIssuesWithSummary, rankCandidateIssuesWithSummary, goalSpecContentByRepo, @@ -601,6 +620,7 @@ describe("runDiscover (#4247)", () => { nowMs: NOW, fetchCandidateIssuesWithSummary, initPortfolioQueue: () => portfolioQueue, + initPolicyVerdictCache: () => tempPolicyVerdictCacheStore(), }); expect(exitCode).toBe(0); expect(existsSync(cacheDbPath)).toBe(true); @@ -631,6 +651,7 @@ describe("runDiscover (#4247)", () => { nowMs: NOW, initPortfolioQueue: () => portfolioQueue, initPolicyDocCache, + initPolicyVerdictCache: () => tempPolicyVerdictCacheStore(), fetchCandidateIssuesWithSummary, }); @@ -644,6 +665,74 @@ describe("runDiscover (#4247)", () => { expect.objectContaining({ policyDocCache: null }), ); }); + + it("opens and closes the default on-disk policy-verdict cache when no override is supplied", async () => { + const root = mkdtempSync(join(tmpdir(), "gittensory-miner-discover-cli-pvc-default-")); + roots.push(root); + const cacheDbPath = join(root, "policy-verdict-cache.sqlite3"); + const previousCacheDbPath = process.env.GITTENSORY_MINER_POLICY_VERDICT_CACHE_DB; + process.env.GITTENSORY_MINER_POLICY_VERDICT_CACHE_DB = cacheDbPath; + try { + const portfolioQueue = tempQueueStore(); + const fetchCandidateIssuesWithSummary = vi.fn(async () => ({ + issues: [fanOutIssue()], + warnings: [], + rateLimitRemaining: null, + rateLimitResetAt: null, + })); + vi.spyOn(console, "log").mockImplementation(() => undefined); + + // No initPolicyVerdictCache override: runDiscover opens the default on-disk cache at the env path and closes + // it in its finally block. Reopening the same file confirms the default code path created a usable store. + const exitCode = await runDiscover(["acme/widgets"], { + nowMs: NOW, + fetchCandidateIssuesWithSummary, + initPortfolioQueue: () => portfolioQueue, + initPolicyDocCache: () => tempPolicyDocCacheStore(), + }); + expect(exitCode).toBe(0); + expect(existsSync(cacheDbPath)).toBe(true); + + const reopened = initPolicyVerdictCacheStore(cacheDbPath); + stores.push(reopened); + expect(reopened.get("acme/widgets")).toBeNull(); + } finally { + if (previousCacheDbPath === undefined) delete process.env.GITTENSORY_MINER_POLICY_VERDICT_CACHE_DB; + else process.env.GITTENSORY_MINER_POLICY_VERDICT_CACHE_DB = previousCacheDbPath; + } + }); + + it("REGRESSION: a corrupt/unopenable policy-verdict cache degrades to no cache instead of failing discovery", async () => { + const portfolioQueue = tempQueueStore(); + const fetchCandidateIssuesWithSummary = vi.fn(async () => ({ + issues: [fanOutIssue()], + warnings: [], + rateLimitRemaining: null, + rateLimitResetAt: null, + })); + vi.spyOn(console, "log").mockImplementation(() => undefined); + const initPolicyVerdictCache = vi.fn(() => { + throw new Error("disk full"); + }); + + const exitCode = await runDiscover(["acme/widgets"], { + nowMs: NOW, + initPortfolioQueue: () => portfolioQueue, + initPolicyDocCache: () => tempPolicyDocCacheStore(), + initPolicyVerdictCache, + fetchCandidateIssuesWithSummary, + }); + + // Same discipline as the policy-doc cache above: a pure performance optimization, so an open failure must + // never abort discovery. + expect(exitCode).toBe(0); + expect(initPolicyVerdictCache).toHaveBeenCalledTimes(1); + expect(fetchCandidateIssuesWithSummary).toHaveBeenCalledWith( + [{ owner: "acme", repo: "widgets" }], + "", + expect.objectContaining({ policyVerdictCache: null }), + ); + }); }); describe("gittensory-miner discover CLI entrypoint (#4247)", () => { diff --git a/test/unit/miner-local-store-readme.test.ts b/test/unit/miner-local-store-readme.test.ts index 76f51c0321..827a84746f 100644 --- a/test/unit/miner-local-store-readme.test.ts +++ b/test/unit/miner-local-store-readme.test.ts @@ -24,6 +24,7 @@ describe("gittensory-miner local storage README (#4272, #4876)", () => { ["worktree-allocator.sqlite3", "worktree_slots", "worktree-allocator.js", "GITTENSORY_MINER_WORKTREE_ALLOCATOR_DB"], ["orb-export.sqlite3", "orb_export_meta", "orb-export.js", "GITTENSORY_MINER_ORB_EXPORT_DB"], ["policy-doc-cache.sqlite3", "policy_doc_cache", "policy-doc-cache.js", "GITTENSORY_MINER_POLICY_DOC_CACHE_DB"], + ["policy-verdict-cache.sqlite3", "policy_verdict_cache", "policy-verdict-cache.js", "GITTENSORY_MINER_POLICY_VERDICT_CACHE_DB"], ]) { for (const token of row) expect(readme).toContain(token); } diff --git a/test/unit/miner-policy-verdict-cache.test.ts b/test/unit/miner-policy-verdict-cache.test.ts new file mode 100644 index 0000000000..0e62e24361 --- /dev/null +++ b/test/unit/miner-policy-verdict-cache.test.ts @@ -0,0 +1,138 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { AiPolicyVerdict } from "@jsonbored/gittensory-engine"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + initPolicyVerdictCacheStore, + resolvePolicyVerdictCacheDbPath, +} from "../../packages/gittensory-miner/lib/policy-verdict-cache.js"; + +const roots: string[] = []; +const stores: Array<{ close(): void }> = []; + +function tempDbPath(): string { + const root = mkdtempSync(join(tmpdir(), "gittensory-miner-policy-verdict-cache-")); + roots.push(root); + return join(root, "policy-verdict-cache.sqlite3"); +} + +function openStore(dbPath = ":memory:") { + const store = initPolicyVerdictCacheStore(dbPath); + stores.push(store); + return store; +} + +const REPO = "acme/widgets"; +const VERDICT = { allowed: true, matchedPhrase: null, source: "AI-USAGE.md" } as const; + +afterEach(() => { + for (const store of stores.splice(0)) store.close(); + vi.unstubAllEnvs(); + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("resolvePolicyVerdictCacheDbPath (#4843)", () => { + it("prefers the store-specific env var, then the config dir, then XDG/~config", () => { + expect(resolvePolicyVerdictCacheDbPath({ GITTENSORY_MINER_POLICY_VERDICT_CACHE_DB: "/custom/pvc.sqlite3" })).toBe( + "/custom/pvc.sqlite3", + ); + expect(resolvePolicyVerdictCacheDbPath({ GITTENSORY_MINER_CONFIG_DIR: "/cfg" })).toBe( + join("/cfg", "policy-verdict-cache.sqlite3"), + ); + expect(resolvePolicyVerdictCacheDbPath({ XDG_CONFIG_HOME: "/xdg" })).toBe( + join("/xdg", "gittensory-miner", "policy-verdict-cache.sqlite3"), + ); + }); +}); + +describe("gittensory-miner policy-verdict cache store (#4843)", () => { + it("returns null for a repo that has never been cached", () => { + expect(openStore().get(REPO)).toBeNull(); + }); + + it("stores and reads back a decisive doc + ETag + verdict, and reports its db path", () => { + const store = openStore(); + const write = store.put(REPO, "AI-USAGE.md", '"v1"', VERDICT); + expect(write).toMatchObject({ repoFullName: REPO, decisiveDoc: "AI-USAGE.md", etag: '"v1"', verdict: VERDICT }); + expect(typeof write.updatedAt).toBe("string"); + expect(store.get(REPO)).toEqual({ decisiveDoc: "AI-USAGE.md", etag: '"v1"', verdict: VERDICT }); + expect(store.dbPath).toBe(":memory:"); + }); + + it("overwrites the prior entry for the same repo (ON CONFLICT upsert)", () => { + const store = openStore(); + store.put(REPO, "AI-USAGE.md", '"v1"', VERDICT); + const closedVerdict = { allowed: false, matchedPhrase: "no ai contributions", source: "CONTRIBUTING.md" } as const; + store.put(REPO, "CONTRIBUTING.md", '"v2"', closedVerdict); + expect(store.get(REPO)).toEqual({ decisiveDoc: "CONTRIBUTING.md", etag: '"v2"', verdict: closedVerdict }); + }); + + it("round-trips a verdict carrying an optional fatigue field", () => { + const store = openStore(); + const withFatigue: AiPolicyVerdict = { + ...VERDICT, + fatigue: { level: "watch", priorityAdjustment: "deprioritize", score: 3, recheckAfterHours: 24, evidence: [] }, + }; + store.put(REPO, "AI-USAGE.md", '"v1"', withFatigue); + expect(store.get(REPO)?.verdict).toEqual(withFatigue); + }); + + it("rejects a non-string or empty repoFullName on both get and put", () => { + const store = openStore(); + expect(() => store.get("")).toThrow("invalid_policy_verdict_repo_full_name"); + expect(() => store.get(" ")).toThrow("invalid_policy_verdict_repo_full_name"); + // @ts-expect-error deliberately passing a non-string to exercise the guard. + expect(() => store.get(42)).toThrow("invalid_policy_verdict_repo_full_name"); + expect(() => store.put("", "AI-USAGE.md", '"v1"', VERDICT)).toThrow("invalid_policy_verdict_repo_full_name"); + }); + + it("rejects a decisive doc outside the AI-USAGE.md/CONTRIBUTING.md pair", () => { + const store = openStore(); + // @ts-expect-error deliberately passing an invalid decisive doc. + expect(() => store.put(REPO, "none", '"v1"', VERDICT)).toThrow("invalid_policy_verdict_decisive_doc"); + // @ts-expect-error deliberately passing a non-string decisive doc. + expect(() => store.put(REPO, null, '"v1"', VERDICT)).toThrow("invalid_policy_verdict_decisive_doc"); + }); + + it("rejects a missing/blank ETag", () => { + const store = openStore(); + // @ts-expect-error deliberately passing a non-string etag. + expect(() => store.put(REPO, "AI-USAGE.md", null, VERDICT)).toThrow("invalid_policy_verdict_etag"); + expect(() => store.put(REPO, "AI-USAGE.md", " ", VERDICT)).toThrow("invalid_policy_verdict_etag"); + }); + + it("rejects a non-object verdict", () => { + const store = openStore(); + // @ts-expect-error deliberately passing a non-object verdict. + expect(() => store.put(REPO, "AI-USAGE.md", '"v1"', null)).toThrow("invalid_policy_verdict"); + // @ts-expect-error deliberately passing an array verdict. + expect(() => store.put(REPO, "AI-USAGE.md", '"v1"', [])).toThrow("invalid_policy_verdict"); + // @ts-expect-error deliberately passing a string verdict. + expect(() => store.put(REPO, "AI-USAGE.md", '"v1"', "allowed")).toThrow("invalid_policy_verdict"); + }); + + it("persists entries across a close + reopen of the same on-disk file", () => { + const dbPath = tempDbPath(); + const store = openStore(dbPath); + store.put(REPO, "AI-USAGE.md", '"v1"', VERDICT); + store.close(); + stores.splice(stores.indexOf(store), 1); + + const reopened = openStore(dbPath); + expect(reopened.get(REPO)).toEqual({ decisiveDoc: "AI-USAGE.md", etag: '"v1"', verdict: VERDICT }); + }); + + it("resolves its default path from the env when no path is passed", () => { + const dbPath = tempDbPath(); + vi.stubEnv("GITTENSORY_MINER_POLICY_VERDICT_CACHE_DB", dbPath); + // Call with no argument so the default parameter resolves the path from the env. + const store = initPolicyVerdictCacheStore(); + stores.push(store); + expect(store.dbPath).toBe(dbPath); + }); + + it("throws on an empty explicit db path", () => { + expect(() => initPolicyVerdictCacheStore("")).toThrow("invalid_policy_verdict_cache_db_path"); + }); +}); diff --git a/test/unit/opportunity-fanout-policy-verdict-cache.test.ts b/test/unit/opportunity-fanout-policy-verdict-cache.test.ts new file mode 100644 index 0000000000..e74d8e85b4 --- /dev/null +++ b/test/unit/opportunity-fanout-policy-verdict-cache.test.ts @@ -0,0 +1,273 @@ +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +// Route the miner's bare "@jsonbored/gittensory-engine" import at the engine source (mirrors +// opportunity-fanout-ai-policy.test.ts) so the fan-out uses the real resolveAiPolicyVerdict. +vi.mock("@jsonbored/gittensory-engine", async () => { + return import("../../packages/gittensory-engine/src/index"); +}); + +import { fetchCandidateIssuesWithSummary } from "../../packages/gittensory-miner/lib/opportunity-fanout.js"; +import { initPolicyVerdictCacheStore } from "../../packages/gittensory-miner/lib/policy-verdict-cache.js"; + +const API = "https://api.test"; +const AI_USAGE_URL = `${API}/repos/acme/widgets/contents/AI-USAGE.md`; +const CONTRIBUTING_URL = `${API}/repos/acme/widgets/contents/CONTRIBUTING.md`; +const REPO = "acme/widgets"; + +const fixtureDir = join(dirname(fileURLToPath(import.meta.url)), "../fixtures/ai-policy"); +const ALLOWED_AI_USAGE = readFileSync(join(fixtureDir, "allowed-encourages-ai.md"), "utf8"); + +type FetchCall = { url: string; headers: Record }; + +function headerRecord(init?: RequestInit): Record { + return (init?.headers ?? {}) as Record; +} + +function jsonResponse(body: unknown, init: ResponseInit = {}) { + return Response.json(body, { + ...init, + headers: { "x-ratelimit-remaining": "42", "x-ratelimit-reset": "1800000000", ...(init.headers ?? {}) }, + }); +} + +function contentResponse(content: string, etag?: string) { + const headers: Record = etag === undefined ? {} : { etag }; + return jsonResponse( + { type: "file", encoding: "base64", content: Buffer.from(content, "utf8").toString("base64") }, + { headers }, + ); +} + +const issue = (number: number) => ({ + number, + title: `Issue ${number}`, + labels: ["help wanted"], + comments: 1, + created_at: "2026-07-01T00:00:00Z", + updated_at: "2026-07-01T01:00:00Z", + html_url: `https://github.com/acme/widgets/issues/${number}`, +}); + +/** A minimal in-memory PolicyVerdictCache that records its writes, so a test can assert exactly what got cached. */ +function fakeVerdictCache( + overrides: { getImpl?: (repoFullName: string) => unknown; putImpl?: () => void } = {}, +) { + const store = new Map(); + const puts: Array<{ repoFullName: string; decisiveDoc: string; etag: string; verdict: unknown }> = []; + return { + store, + puts, + get(repoFullName: string) { + if (overrides.getImpl) return overrides.getImpl(repoFullName); + return store.get(repoFullName) ?? null; + }, + put(repoFullName: string, decisiveDoc: string, etag: string, verdict: unknown) { + if (overrides.putImpl) overrides.putImpl(); + const entry = { decisiveDoc, etag, verdict }; + store.set(repoFullName, entry); + puts.push({ repoFullName, decisiveDoc, etag, verdict }); + return { repoFullName, ...entry, updatedAt: "t" }; + }, + }; +} + +/** Stub global fetch: AI-USAGE.md served per `aiUsagePolicy`, CONTRIBUTING.md per `contributingPolicy` (defaults to + * 404, matching most tests that only care about the AI-USAGE.md-decisive path). */ +function stubFetch( + aiUsagePolicy: (call: FetchCall) => Response | Promise, + contributingPolicy: (call: FetchCall) => Response | Promise = () => jsonResponse({}, { status: 404 }), +) { + const calls: FetchCall[] = []; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + const call: FetchCall = { url, headers: headerRecord(init) }; + calls.push(call); + if (url === AI_USAGE_URL) return aiUsagePolicy(call); + if (url === CONTRIBUTING_URL) return contributingPolicy(call); + if (url.includes("/repos/acme/widgets/issues?")) return jsonResponse([issue(1)]); + return jsonResponse({}, { status: 404 }); + }); + return calls; +} + +async function discover(policyVerdictCache: unknown) { + return fetchCandidateIssuesWithSummary([{ owner: "acme", repo: "widgets" }], "token", { + apiBaseUrl: API, + // biome-ignore lint/suspicious/noExplicitAny: the injected fake satisfies the structural PolicyVerdictCache surface. + policyVerdictCache: policyVerdictCache as any, + }); +} + +const roots: string[] = []; +const stores: Array<{ close(): void }> = []; + +afterEach(() => { + for (const store of stores.splice(0)) store.close(); + vi.unstubAllGlobals(); + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("opportunity fan-out policy-verdict cache (#4843)", () => { + it("resolves and caches a fresh verdict on a cold cache", async () => { + const cache = fakeVerdictCache(); + stubFetch(() => contentResponse(ALLOWED_AI_USAGE, '"v1"')); + + const result = await discover(cache); + + expect(result.issues.map((entry) => entry.issueNumber)).toEqual([1]); + expect(cache.puts).toHaveLength(1); + expect(cache.puts[0]).toMatchObject({ repoFullName: REPO, decisiveDoc: "AI-USAGE.md", etag: '"v1"' }); + }); + + it("reuses a cached verdict outright when the decisive doc's ETag is unchanged", async () => { + const cache = fakeVerdictCache(); + // Deliberately WRONG (blocking) verdict planted under a matching decisiveDoc + ETag: if the fresh "allowed" + // fixture were re-resolved, the issue would survive. Its absence proves the cached verdict won, not a fresh one. + cache.store.set(REPO, { + decisiveDoc: "AI-USAGE.md", + etag: '"v1"', + verdict: { allowed: false, matchedPhrase: "fake-cached-ban", source: "AI-USAGE.md" }, + }); + stubFetch(() => contentResponse(ALLOWED_AI_USAGE, '"v1"')); + + const result = await discover(cache); + + expect(result.issues).toEqual([]); + // A cache hit never re-writes: the entry set up above is untouched. + expect(cache.puts).toEqual([]); + }); + + it("recomputes when the decisive doc's ETag has changed", async () => { + const cache = fakeVerdictCache(); + cache.store.set(REPO, { + decisiveDoc: "AI-USAGE.md", + etag: '"v-old"', + verdict: { allowed: false, matchedPhrase: "fake-cached-ban", source: "AI-USAGE.md" }, + }); + stubFetch(() => contentResponse(ALLOWED_AI_USAGE, '"v-new"')); + + const result = await discover(cache); + + expect(result.issues.map((entry) => entry.issueNumber)).toEqual([1]); + expect(cache.puts).toHaveLength(1); + expect(cache.puts[0]).toMatchObject({ decisiveDoc: "AI-USAGE.md", etag: '"v-new"' }); + }); + + it("recomputes when the cached entry's decisive doc differs, even with a matching ETag", async () => { + const cache = fakeVerdictCache(); + // Simulates a repo that previously had no AI-USAGE.md (CONTRIBUTING.md was decisive) and now does; the + // decisiveDoc mismatch alone must force a recompute even though this ETag string happens to collide. + cache.store.set(REPO, { + decisiveDoc: "CONTRIBUTING.md", + etag: '"v1"', + verdict: { allowed: false, matchedPhrase: "fake-cached-ban", source: "CONTRIBUTING.md" }, + }); + stubFetch(() => contentResponse(ALLOWED_AI_USAGE, '"v1"')); + + const result = await discover(cache); + + expect(result.issues.map((entry) => entry.issueNumber)).toEqual([1]); + expect(cache.puts).toHaveLength(1); + expect(cache.puts[0]).toMatchObject({ decisiveDoc: "AI-USAGE.md", etag: '"v1"' }); + }); + + it("caches and reuses a verdict decided by CONTRIBUTING.md when AI-USAGE.md is absent", async () => { + const cache = fakeVerdictCache(); + const calls = stubFetch( + () => jsonResponse({}, { status: 404 }), + () => contentResponse(ALLOWED_AI_USAGE, '"c1"'), + ); + + const first = await discover(cache); + expect(first.issues.map((entry) => entry.issueNumber)).toEqual([1]); + expect(cache.puts).toHaveLength(1); + expect(cache.puts[0]).toMatchObject({ decisiveDoc: "CONTRIBUTING.md", etag: '"c1"' }); + const contributingCallCount = calls.filter((call) => call.url === CONTRIBUTING_URL).length; + + // Second run: same ETag on CONTRIBUTING.md -> the cached verdict is reused (no second write). + cache.puts.length = 0; + const second = await discover(cache); + expect(second.issues.map((entry) => entry.issueNumber)).toEqual([1]); + expect(cache.puts).toEqual([]); + expect(calls.filter((call) => call.url === CONTRIBUTING_URL).length).toBeGreaterThan(contributingCallCount); + }); + + it("treats a cache read failure as a miss and still resolves + writes fresh", async () => { + const cache = fakeVerdictCache({ + getImpl: () => { + throw new Error("corrupt cache"); + }, + }); + stubFetch(() => contentResponse(ALLOWED_AI_USAGE, '"v1"')); + + const result = await discover(cache); + + expect(result.issues.map((entry) => entry.issueNumber)).toEqual([1]); + expect(cache.puts).toHaveLength(1); + }); + + it("never fails discovery when the cache write throws", async () => { + const cache = fakeVerdictCache({ + putImpl: () => { + throw new Error("disk full"); + }, + }); + stubFetch(() => contentResponse(ALLOWED_AI_USAGE, '"v1"')); + + const result = await discover(cache); + + expect(result.issues.map((entry) => entry.issueNumber)).toEqual([1]); + expect(result.warnings).toEqual([]); + }); + + it("does not cache when no doc carries an ETag (both docs absent)", async () => { + const cache = fakeVerdictCache(); + stubFetch( + () => jsonResponse({}, { status: 404 }), + () => jsonResponse({}, { status: 404 }), + ); + + const result = await discover(cache); + + // Neither doc exists: resolveAiPolicyVerdict({aiUsage: null, contributing: null}) silently allows. + expect(result.issues.map((entry) => entry.issueNumber)).toEqual([1]); + expect(cache.puts).toEqual([]); + }); + + it("resolves normally when no cache is supplied (feature is inert without one)", async () => { + stubFetch(() => contentResponse(ALLOWED_AI_USAGE, '"v1"')); + + const result = await fetchCandidateIssuesWithSummary([{ owner: "acme", repo: "widgets" }], "token", { + apiBaseUrl: API, + }); + + expect(result.issues.map((entry) => entry.issueNumber)).toEqual([1]); + }); + + it("persists across two runs with the real on-disk store, reusing the verdict on an unchanged ETag", async () => { + const root = mkdtempSync(join(tmpdir(), "gittensory-miner-policy-verdict-cache-fanout-")); + roots.push(root); + const dbPath = join(root, "policy-verdict-cache.sqlite3"); + const store = initPolicyVerdictCacheStore(dbPath); + stores.push(store); + + stubFetch(() => contentResponse(ALLOWED_AI_USAGE, '"v1"')); + const first = await discover(store); + expect(first.issues.map((entry) => entry.issueNumber)).toEqual([1]); + expect(store.get(REPO)).toMatchObject({ decisiveDoc: "AI-USAGE.md", etag: '"v1"' }); + + vi.unstubAllGlobals(); + stubFetch(() => contentResponse(ALLOWED_AI_USAGE, '"v1"')); + const second = await discover(store); + expect(second.issues.map((entry) => entry.issueNumber)).toEqual([1]); + + // The verdict really landed on disk: a freshly reopened handle still has it. + const reopened = initPolicyVerdictCacheStore(dbPath); + stores.push(reopened); + expect(reopened.get(REPO)).toMatchObject({ decisiveDoc: "AI-USAGE.md", etag: '"v1"' }); + }); +}); From 7ab8e0aa537b8c541e69518e909780986f65fd10 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Sun, 12 Jul 2026 17:45:32 -0700 Subject: [PATCH 2/2] fix(miner): scope the policy-verdict cache key by forge host, not just repo A bare owner/repo is not a safe cache key across tenants: two different forge hosts (#4784's per-tenant apiBaseUrl) can each have their own unrelated repo of the same name, and if their policy docs happened to produce the same ETag string, a verdict resolved against one host's docs could be incorrectly served for the other's. Key the cache by `${apiBaseUrl}::${repoFullName}` instead, mirroring policy-doc-cache.js's own precedent of keying on the full request URL rather than a bare path. Renames the store's column/field from repo_full_name to repo_scope to reflect that the value is now a caller-owned composite key, not a plain repo identifier -- the store itself stays opaque to what the key represents, same as before. --- .../lib/opportunity-fanout.js | 42 ++++++----- .../lib/policy-verdict-cache.d.ts | 8 ++- .../lib/policy-verdict-cache.js | 45 ++++++------ test/unit/miner-policy-verdict-cache.test.ts | 12 ++-- ...tunity-fanout-policy-verdict-cache.test.ts | 71 ++++++++++++++----- 5 files changed, 115 insertions(+), 63 deletions(-) diff --git a/packages/gittensory-miner/lib/opportunity-fanout.js b/packages/gittensory-miner/lib/opportunity-fanout.js index c562efaada..2786da4e95 100644 --- a/packages/gittensory-miner/lib/opportunity-fanout.js +++ b/packages/gittensory-miner/lib/opportunity-fanout.js @@ -176,13 +176,21 @@ function writeCachedPolicyDoc(cache, url, etag, content) { } } -// Read a repo's previously-resolved verdict for the SAME decisive doc + ETag (#4843). A cache that is absent, or -// whose read throws (corrupt/locked file), is treated as a plain miss — the caller resolves the verdict fresh, -// per the same "never risk a stale policy" rule as the doc cache above. -function readCachedPolicyVerdict(cache, repoFullName) { +// A bare `owner/repo` is NOT a safe policy-verdict cache key: two different tenant forge hosts (#4784's +// per-tenant `apiBaseUrl`) can each have their own unrelated repo of the same name, and their policy docs are +// wholly independent. Scope the key by host, mirroring policy-doc-cache.js's own precedent of keying on the full +// request URL rather than a bare path. +function policyVerdictCacheKey(apiBaseUrl, repoFullName) { + return `${apiBaseUrl}::${repoFullName}`; +} + +// Read a repo scope's previously-resolved verdict for the SAME decisive doc + ETag (#4843). A cache that is +// absent, or whose read throws (corrupt/locked file), is treated as a plain miss — the caller resolves the +// verdict fresh, per the same "never risk a stale policy" rule as the doc cache above. +function readCachedPolicyVerdict(cache, repoScope) { if (!cache) return null; try { - return cache.get(repoFullName); + return cache.get(repoScope); } catch { return null; } @@ -191,12 +199,12 @@ function readCachedPolicyVerdict(cache, repoFullName) { // Persist the freshly-resolved verdict against the ETag of the doc that decided it, so the next run can reuse it // outright once that ETag is confirmed unchanged. Only ever called with a real ETag; a write that throws must // never fail discovery — it degrades to "not cached", so the next run just resolves the verdict again. -function writeCachedPolicyVerdict(cache, repoFullName, decisiveDoc, etag, verdict) { +function writeCachedPolicyVerdict(cache, repoScope, decisiveDoc, etag, verdict) { if (!cache || etag === null) return; try { - cache.put(repoFullName, decisiveDoc, etag, verdict); + cache.put(repoScope, decisiveDoc, etag, verdict); } catch { - // Leave this repo uncached; the next run just resolves the verdict fresh again. + // Leave this repo scope uncached; the next run just resolves the verdict fresh again. } } @@ -229,20 +237,22 @@ async function fetchRepoDoc(target, path, githubToken, options, summary, warning } } -// Resolve a repo's AI-usage-policy verdict, reusing a cached one when the deciding doc's ETag hasn't moved since -// it was last resolved (#4843). Only ever consulted with an ETag that a same-run conditional-GET just confirmed -// is current, so a cache hit is exactly as correct as recomputing — it just skips the (cheap, but not free) parse. -function resolveOrCacheVerdict(cache, repoFullName, decisiveDoc, etag, computeVerdict) { +// Resolve a repo scope's AI-usage-policy verdict, reusing a cached one when the deciding doc's ETag hasn't moved +// since it was last resolved (#4843). Only ever consulted with an ETag that a same-run conditional-GET just +// confirmed is current, so a cache hit is exactly as correct as recomputing — it just skips the (cheap, but not +// free) parse. +function resolveOrCacheVerdict(cache, repoScope, decisiveDoc, etag, computeVerdict) { if (etag !== null) { - const cached = readCachedPolicyVerdict(cache, repoFullName); + const cached = readCachedPolicyVerdict(cache, repoScope); if (cached && cached.decisiveDoc === decisiveDoc && cached.etag === etag) return cached.verdict; } const verdict = computeVerdict(); - writeCachedPolicyVerdict(cache, repoFullName, decisiveDoc, etag, verdict); + writeCachedPolicyVerdict(cache, repoScope, decisiveDoc, etag, verdict); return verdict; } async function resolveRepoAiPolicy(target, githubToken, options, summary, warnings) { + const repoScope = policyVerdictCacheKey(options.apiBaseUrl, target.repoFullName); const { content: aiUsage, etag: aiUsageEtag } = await fetchRepoDoc( target, "AI-USAGE.md", @@ -256,7 +266,7 @@ async function resolveRepoAiPolicy(target, githubToken, options, summary, warnin // CONTRIBUTING.md (the exact case resolveAiPolicyVerdict was fixed to handle in #2900, which can only fire if // both docs reach it). if (aiUsage !== null && aiUsage.trim().length > 0) { - return resolveOrCacheVerdict(options.policyVerdictCache, target.repoFullName, "AI-USAGE.md", aiUsageEtag, () => + return resolveOrCacheVerdict(options.policyVerdictCache, repoScope, "AI-USAGE.md", aiUsageEtag, () => resolveAiPolicyVerdict({ aiUsage, contributing: null }), ); } @@ -270,7 +280,7 @@ async function resolveRepoAiPolicy(target, githubToken, options, summary, warnin ); return resolveOrCacheVerdict( options.policyVerdictCache, - target.repoFullName, + repoScope, "CONTRIBUTING.md", contributingEtag, () => resolveAiPolicyVerdict({ aiUsage: null, contributing }), diff --git a/packages/gittensory-miner/lib/policy-verdict-cache.d.ts b/packages/gittensory-miner/lib/policy-verdict-cache.d.ts index e1125d7728..7a4bd3ad87 100644 --- a/packages/gittensory-miner/lib/policy-verdict-cache.d.ts +++ b/packages/gittensory-miner/lib/policy-verdict-cache.d.ts @@ -9,15 +9,17 @@ export type PolicyVerdictCacheEntry = { }; export type PolicyVerdictCacheWrite = PolicyVerdictCacheEntry & { - repoFullName: string; + repoScope: string; updatedAt: string; }; export type PolicyVerdictCacheStore = { dbPath: string; - get(repoFullName: string): PolicyVerdictCacheEntry | null; + /** `repoScope` must uniquely identify a tenant forge host + repo (see `policyVerdictCacheKey` in + * opportunity-fanout.js) -- a bare `owner/repo` is not safe across multiple forge hosts. */ + get(repoScope: string): PolicyVerdictCacheEntry | null; put( - repoFullName: string, + repoScope: string, decisiveDoc: PolicyVerdictDecisiveDoc, etag: string, verdict: AiPolicyVerdict, diff --git a/packages/gittensory-miner/lib/policy-verdict-cache.js b/packages/gittensory-miner/lib/policy-verdict-cache.js index e152eac1a9..3f87e6094c 100644 --- a/packages/gittensory-miner/lib/policy-verdict-cache.js +++ b/packages/gittensory-miner/lib/policy-verdict-cache.js @@ -3,11 +3,16 @@ import { applySchemaMigrations } from "./schema-version.js"; // Local cache of resolved AI-usage-policy verdicts (#4843). Even with #4842's conditional-GET doc cache, the small // but non-zero cost of resolving `resolveAiPolicyVerdict` from raw doc text was still paid on every discover run. -// This stores the verdict itself, keyed by repo + the ETag of whichever doc actually decided it, so a repeat run -// against an unchanged repo reuses the prior verdict outright once opportunity-fanout.js's same-run conditional-GET -// confirms that doc's ETag hasn't moved -- never served blindly, exactly the same "cheaper, never less correct" -// discipline as policy-doc-cache.js. 100% local/client-side, same as every other store this package owns via -// local-store.js: the file lives only on this machine and is never uploaded, synced, or phoned home with. +// This stores the verdict itself, keyed by repo SCOPE (the tenant's `apiBaseUrl` plus `owner/repo` -- see +// `policyVerdictCacheKey` in opportunity-fanout.js, same "the caller owns what makes a cache key" precedent as +// policy-doc-cache.js keying on the full request URL) + the ETag of whichever doc actually decided it, so a +// repeat run against an unchanged repo reuses the prior verdict outright once opportunity-fanout.js's same-run +// conditional-GET confirms that doc's ETag hasn't moved -- never served blindly, exactly the same "cheaper, never +// less correct" discipline as policy-doc-cache.js. `owner/repo` alone is NOT a safe key: two different tenant +// forge hosts can each have their own unrelated `acme/widgets`, and without the host in the key a verdict +// resolved against one host's docs could be served for the other's. 100% local/client-side, same as every other +// store this package owns via local-store.js: the file lives only on this machine and is never uploaded, synced, +// or phoned home with. const defaultDbFileName = "policy-verdict-cache.sqlite3"; const DECISIVE_DOCS = new Set(["AI-USAGE.md", "CONTRIBUTING.md"]); @@ -20,10 +25,10 @@ function normalizeDbPath(dbPath) { return normalizeLocalStoreDbPath(dbPath, resolvePolicyVerdictCacheDbPath(), "invalid_policy_verdict_cache_db_path"); } -function normalizeRepoFullName(repoFullName) { - if (typeof repoFullName !== "string") throw new Error("invalid_policy_verdict_repo_full_name"); - const trimmed = repoFullName.trim(); - if (!trimmed) throw new Error("invalid_policy_verdict_repo_full_name"); +function normalizeRepoScope(repoScope) { + if (typeof repoScope !== "string") throw new Error("invalid_policy_verdict_repo_scope"); + const trimmed = repoScope.trim(); + if (!trimmed) throw new Error("invalid_policy_verdict_repo_scope"); return trimmed; } @@ -53,7 +58,7 @@ export function initPolicyVerdictCacheStore(dbPath = resolvePolicyVerdictCacheDb const db = openLocalStoreDb(resolvedPath); db.exec(` CREATE TABLE IF NOT EXISTS policy_verdict_cache ( - repo_full_name TEXT PRIMARY KEY, + repo_scope TEXT PRIMARY KEY, decisive_doc TEXT NOT NULL, etag TEXT NOT NULL, verdict TEXT NOT NULL, @@ -64,12 +69,12 @@ export function initPolicyVerdictCacheStore(dbPath = resolvePolicyVerdictCacheDb applySchemaMigrations(db, []); const getStatement = db.prepare( - "SELECT decisive_doc, etag, verdict FROM policy_verdict_cache WHERE repo_full_name = ?", + "SELECT decisive_doc, etag, verdict FROM policy_verdict_cache WHERE repo_scope = ?", ); const putStatement = db.prepare(` - INSERT INTO policy_verdict_cache (repo_full_name, decisive_doc, etag, verdict, updated_at) + INSERT INTO policy_verdict_cache (repo_scope, decisive_doc, etag, verdict, updated_at) VALUES (?, ?, ?, ?, ?) - ON CONFLICT(repo_full_name) DO UPDATE SET + ON CONFLICT(repo_scope) DO UPDATE SET decisive_doc = excluded.decisive_doc, etag = excluded.etag, verdict = excluded.verdict, @@ -78,21 +83,21 @@ export function initPolicyVerdictCacheStore(dbPath = resolvePolicyVerdictCacheDb return { dbPath: resolvedPath, - /** The last-known `{ decisiveDoc, etag, verdict }` for a repo, or null when it has never been cached. */ - get(repoFullName) { - const row = getStatement.get(normalizeRepoFullName(repoFullName)); + /** The last-known `{ decisiveDoc, etag, verdict }` for a repo scope, or null when it has never been cached. */ + get(repoScope) { + const row = getStatement.get(normalizeRepoScope(repoScope)); if (!row) return null; return { decisiveDoc: row.decisive_doc, etag: row.etag, verdict: JSON.parse(row.verdict) }; }, /** Record the resolved verdict against the ETag of the doc that decided it, so the next run can reuse it. */ - put(repoFullName, decisiveDoc, etag, verdict) { - const normalizedRepoFullName = normalizeRepoFullName(repoFullName); + put(repoScope, decisiveDoc, etag, verdict) { + const normalizedRepoScope = normalizeRepoScope(repoScope); const normalizedDecisiveDoc = normalizeDecisiveDoc(decisiveDoc); const normalizedEtag = normalizeEtag(etag); const serializedVerdict = serializeVerdict(verdict); const updatedAt = new Date().toISOString(); - putStatement.run(normalizedRepoFullName, normalizedDecisiveDoc, normalizedEtag, serializedVerdict, updatedAt); - return { repoFullName: normalizedRepoFullName, decisiveDoc: normalizedDecisiveDoc, etag: normalizedEtag, verdict, updatedAt }; + putStatement.run(normalizedRepoScope, normalizedDecisiveDoc, normalizedEtag, serializedVerdict, updatedAt); + return { repoScope: normalizedRepoScope, decisiveDoc: normalizedDecisiveDoc, etag: normalizedEtag, verdict, updatedAt }; }, close() { db.close(); diff --git a/test/unit/miner-policy-verdict-cache.test.ts b/test/unit/miner-policy-verdict-cache.test.ts index 0e62e24361..e4ebac45b3 100644 --- a/test/unit/miner-policy-verdict-cache.test.ts +++ b/test/unit/miner-policy-verdict-cache.test.ts @@ -54,7 +54,7 @@ describe("gittensory-miner policy-verdict cache store (#4843)", () => { it("stores and reads back a decisive doc + ETag + verdict, and reports its db path", () => { const store = openStore(); const write = store.put(REPO, "AI-USAGE.md", '"v1"', VERDICT); - expect(write).toMatchObject({ repoFullName: REPO, decisiveDoc: "AI-USAGE.md", etag: '"v1"', verdict: VERDICT }); + expect(write).toMatchObject({ repoScope: REPO, decisiveDoc: "AI-USAGE.md", etag: '"v1"', verdict: VERDICT }); expect(typeof write.updatedAt).toBe("string"); expect(store.get(REPO)).toEqual({ decisiveDoc: "AI-USAGE.md", etag: '"v1"', verdict: VERDICT }); expect(store.dbPath).toBe(":memory:"); @@ -78,13 +78,13 @@ describe("gittensory-miner policy-verdict cache store (#4843)", () => { expect(store.get(REPO)?.verdict).toEqual(withFatigue); }); - it("rejects a non-string or empty repoFullName on both get and put", () => { + it("rejects a non-string or empty repoScope on both get and put", () => { const store = openStore(); - expect(() => store.get("")).toThrow("invalid_policy_verdict_repo_full_name"); - expect(() => store.get(" ")).toThrow("invalid_policy_verdict_repo_full_name"); + expect(() => store.get("")).toThrow("invalid_policy_verdict_repo_scope"); + expect(() => store.get(" ")).toThrow("invalid_policy_verdict_repo_scope"); // @ts-expect-error deliberately passing a non-string to exercise the guard. - expect(() => store.get(42)).toThrow("invalid_policy_verdict_repo_full_name"); - expect(() => store.put("", "AI-USAGE.md", '"v1"', VERDICT)).toThrow("invalid_policy_verdict_repo_full_name"); + expect(() => store.get(42)).toThrow("invalid_policy_verdict_repo_scope"); + expect(() => store.put("", "AI-USAGE.md", '"v1"', VERDICT)).toThrow("invalid_policy_verdict_repo_scope"); }); it("rejects a decisive doc outside the AI-USAGE.md/CONTRIBUTING.md pair", () => { diff --git a/test/unit/opportunity-fanout-policy-verdict-cache.test.ts b/test/unit/opportunity-fanout-policy-verdict-cache.test.ts index e74d8e85b4..e5df64e058 100644 --- a/test/unit/opportunity-fanout-policy-verdict-cache.test.ts +++ b/test/unit/opportunity-fanout-policy-verdict-cache.test.ts @@ -16,7 +16,9 @@ import { initPolicyVerdictCacheStore } from "../../packages/gittensory-miner/lib const API = "https://api.test"; const AI_USAGE_URL = `${API}/repos/acme/widgets/contents/AI-USAGE.md`; const CONTRIBUTING_URL = `${API}/repos/acme/widgets/contents/CONTRIBUTING.md`; -const REPO = "acme/widgets"; +// Cache keys are scoped by tenant host + repo (#4784/#4843's own fix), not a bare "owner/repo" -- see +// policyVerdictCacheKey in opportunity-fanout.js. +const REPO_SCOPE = `${API}::acme/widgets`; const fixtureDir = join(dirname(fileURLToPath(import.meta.url)), "../fixtures/ai-policy"); const ALLOWED_AI_USAGE = readFileSync(join(fixtureDir, "allowed-encourages-ai.md"), "utf8"); @@ -54,23 +56,23 @@ const issue = (number: number) => ({ /** A minimal in-memory PolicyVerdictCache that records its writes, so a test can assert exactly what got cached. */ function fakeVerdictCache( - overrides: { getImpl?: (repoFullName: string) => unknown; putImpl?: () => void } = {}, + overrides: { getImpl?: (repoScope: string) => unknown; putImpl?: () => void } = {}, ) { const store = new Map(); - const puts: Array<{ repoFullName: string; decisiveDoc: string; etag: string; verdict: unknown }> = []; + const puts: Array<{ repoScope: string; decisiveDoc: string; etag: string; verdict: unknown }> = []; return { store, puts, - get(repoFullName: string) { - if (overrides.getImpl) return overrides.getImpl(repoFullName); - return store.get(repoFullName) ?? null; + get(repoScope: string) { + if (overrides.getImpl) return overrides.getImpl(repoScope); + return store.get(repoScope) ?? null; }, - put(repoFullName: string, decisiveDoc: string, etag: string, verdict: unknown) { + put(repoScope: string, decisiveDoc: string, etag: string, verdict: unknown) { if (overrides.putImpl) overrides.putImpl(); const entry = { decisiveDoc, etag, verdict }; - store.set(repoFullName, entry); - puts.push({ repoFullName, decisiveDoc, etag, verdict }); - return { repoFullName, ...entry, updatedAt: "t" }; + store.set(repoScope, entry); + puts.push({ repoScope, decisiveDoc, etag, verdict }); + return { repoScope, ...entry, updatedAt: "t" }; }, }; } @@ -94,9 +96,9 @@ function stubFetch( return calls; } -async function discover(policyVerdictCache: unknown) { +async function discover(policyVerdictCache: unknown, apiBaseUrl: string = API) { return fetchCandidateIssuesWithSummary([{ owner: "acme", repo: "widgets" }], "token", { - apiBaseUrl: API, + apiBaseUrl, // biome-ignore lint/suspicious/noExplicitAny: the injected fake satisfies the structural PolicyVerdictCache surface. policyVerdictCache: policyVerdictCache as any, }); @@ -120,14 +122,14 @@ describe("opportunity fan-out policy-verdict cache (#4843)", () => { expect(result.issues.map((entry) => entry.issueNumber)).toEqual([1]); expect(cache.puts).toHaveLength(1); - expect(cache.puts[0]).toMatchObject({ repoFullName: REPO, decisiveDoc: "AI-USAGE.md", etag: '"v1"' }); + expect(cache.puts[0]).toMatchObject({ repoScope: REPO_SCOPE, decisiveDoc: "AI-USAGE.md", etag: '"v1"' }); }); it("reuses a cached verdict outright when the decisive doc's ETag is unchanged", async () => { const cache = fakeVerdictCache(); // Deliberately WRONG (blocking) verdict planted under a matching decisiveDoc + ETag: if the fresh "allowed" // fixture were re-resolved, the issue would survive. Its absence proves the cached verdict won, not a fresh one. - cache.store.set(REPO, { + cache.store.set(REPO_SCOPE, { decisiveDoc: "AI-USAGE.md", etag: '"v1"', verdict: { allowed: false, matchedPhrase: "fake-cached-ban", source: "AI-USAGE.md" }, @@ -143,7 +145,7 @@ describe("opportunity fan-out policy-verdict cache (#4843)", () => { it("recomputes when the decisive doc's ETag has changed", async () => { const cache = fakeVerdictCache(); - cache.store.set(REPO, { + cache.store.set(REPO_SCOPE, { decisiveDoc: "AI-USAGE.md", etag: '"v-old"', verdict: { allowed: false, matchedPhrase: "fake-cached-ban", source: "AI-USAGE.md" }, @@ -161,7 +163,7 @@ describe("opportunity fan-out policy-verdict cache (#4843)", () => { const cache = fakeVerdictCache(); // Simulates a repo that previously had no AI-USAGE.md (CONTRIBUTING.md was decisive) and now does; the // decisiveDoc mismatch alone must force a recompute even though this ETag string happens to collide. - cache.store.set(REPO, { + cache.store.set(REPO_SCOPE, { decisiveDoc: "CONTRIBUTING.md", etag: '"v1"', verdict: { allowed: false, matchedPhrase: "fake-cached-ban", source: "CONTRIBUTING.md" }, @@ -175,6 +177,39 @@ describe("opportunity fan-out policy-verdict cache (#4843)", () => { expect(cache.puts[0]).toMatchObject({ decisiveDoc: "AI-USAGE.md", etag: '"v1"' }); }); + it("REGRESSION: does not share a cached verdict across two different tenant forge hosts with the same owner/repo (#4843)", async () => { + const cache = fakeVerdictCache(); + const secondHost = "https://ghe.example.com/api/v3"; + // Both hosts happen to serve identical bytes (same ETag string too) for their own, wholly unrelated + // "acme/widgets" repo -- a coincidence real ETags can produce (e.g. two hosts both using a weak/hash-derived + // ETag scheme). A bare `owner/repo` cache key would incorrectly treat these as the same repo. + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/contents/AI-USAGE.md")) return contentResponse(ALLOWED_AI_USAGE, '"v1"'); + if (url.includes("/repos/acme/widgets/issues?") || url.includes("/api/v3/repos/acme/widgets/issues?")) { + return jsonResponse([issue(1)]); + } + return jsonResponse({}, { status: 404 }); + }); + + const first = await discover(cache, API); + expect(first.issues.map((entry) => entry.issueNumber)).toEqual([1]); + expect(cache.puts).toHaveLength(1); + expect(cache.puts[0]?.repoScope).toBe(REPO_SCOPE); + + // A DIFFERENT host, same owner/repo, same ETag string: without host-scoping this would hit the FIRST host's + // cache entry and skip resolution entirely. It must instead be treated as a fresh, independent repo. + cache.puts.length = 0; + const second = await discover(cache, secondHost); + expect(second.issues.map((entry) => entry.issueNumber)).toEqual([1]); + expect(cache.puts).toHaveLength(1); + expect(cache.puts[0]?.repoScope).toBe(`${secondHost}::acme/widgets`); + expect(cache.puts[0]?.repoScope).not.toBe(REPO_SCOPE); + + // Both hosts' entries coexist independently in the cache. + expect(cache.store.size).toBe(2); + }); + it("caches and reuses a verdict decided by CONTRIBUTING.md when AI-USAGE.md is absent", async () => { const cache = fakeVerdictCache(); const calls = stubFetch( @@ -258,7 +293,7 @@ describe("opportunity fan-out policy-verdict cache (#4843)", () => { stubFetch(() => contentResponse(ALLOWED_AI_USAGE, '"v1"')); const first = await discover(store); expect(first.issues.map((entry) => entry.issueNumber)).toEqual([1]); - expect(store.get(REPO)).toMatchObject({ decisiveDoc: "AI-USAGE.md", etag: '"v1"' }); + expect(store.get(REPO_SCOPE)).toMatchObject({ decisiveDoc: "AI-USAGE.md", etag: '"v1"' }); vi.unstubAllGlobals(); stubFetch(() => contentResponse(ALLOWED_AI_USAGE, '"v1"')); @@ -268,6 +303,6 @@ describe("opportunity fan-out policy-verdict cache (#4843)", () => { // The verdict really landed on disk: a freshly reopened handle still has it. const reopened = initPolicyVerdictCacheStore(dbPath); stores.push(reopened); - expect(reopened.get(REPO)).toMatchObject({ decisiveDoc: "AI-USAGE.md", etag: '"v1"' }); + expect(reopened.get(REPO_SCOPE)).toMatchObject({ decisiveDoc: "AI-USAGE.md", etag: '"v1"' }); }); });