11/** `discover` CLI command (#4247): wires the existing fanout -> rank -> enqueue pipeline together so a miner
22 * can actually run it. Every piece already exists and is independently tested; this module only composes them. */
3+ import { existsSync } from "node:fs" ;
34import { resolveForgeConfig } from "./forge-config.js" ;
45import type { ForgeConfig } from "./forge-config.js" ;
56import {
@@ -22,6 +23,7 @@ import { initPolicyDocCacheStore } from "./policy-doc-cache.js";
2223import type { PolicyDocCacheStore } from "./policy-doc-cache.js" ;
2324import { initPolicyVerdictCacheStore } from "./policy-verdict-cache.js" ;
2425import type { PolicyVerdictCacheStore } from "./policy-verdict-cache.js" ;
26+ import { isValidRepoSegment } from "./repo-clone.js" ;
2527import { enqueueRankedDiscovery } from "./portfolio-discovery.js" ;
2628import { AMS_MIN_RANK_SHIPPED , readMinRankAutotuneEnabled , readMinRankOverride } from "./ams-calibration.js" ;
2729import { initEventLedger , resolveEventLedgerDbPath } from "./event-ledger.js" ;
@@ -185,11 +187,19 @@ async function supplementWithDiscoveryIndex(
185187 if ( ! isDiscoveryPlaneEnabled ( env ) ) return fanOut ;
186188 const queryIndex = options . queryDiscoveryIndex ?? queryDiscoveryIndex ;
187189 const response = await queryIndex ( queryScope , { env } ) ;
188- recordDiscoveryTelemetry ( "discover_query" , response . candidates . length > 0 ? "supplemented" : "empty" , { env } ) ;
189- if ( response . candidates . length === 0 ) return fanOut ;
190+ // #9680: the hosted index preserves a repo's AI-contribution ban (normalizeDiscoveryIndexCandidate keeps
191+ // aiPolicyAllowed:false), but nothing downstream re-checks it -- the `as RawCandidateIssue` cast below would
192+ // launder a `false` through a type declared as the literal `true`, so an AI-banned repo's issue would be ranked
193+ // and enqueued. Enforce the ban here exactly as the local fetchTargetIssues does (`if (!verdict.allowed) return
194+ // []`): drop it, not down-rank or warn. A candidate that omits the field is kept (`!== false`, matching
195+ // normalizeDiscoveryIndexCandidate's own default).
196+ const aiAllowed = response . candidates . filter ( ( candidate ) => candidate . aiPolicyAllowed !== false ) ;
197+ const droppedAiBanned = response . candidates . length - aiAllowed . length ;
198+ recordDiscoveryTelemetry ( "discover_query" , response . candidates . length > 0 ? "supplemented" : "empty" , { env, droppedAiBanned } ) ;
199+ if ( aiAllowed . length === 0 ) return fanOut ;
190200
191201 const seen = new Set ( fanOut . issues . map ( ( issue ) => dedupeKey ( issue . repoFullName , issue . issueNumber ) ) ) ;
192- const supplemented = response . candidates
202+ const supplemented = aiAllowed
193203 . filter ( ( candidate ) => ! seen . has ( dedupeKey ( candidate . repoFullName , candidate . issueNumber ) ) )
194204 // DiscoveryIndexCandidate is a near-superset of RawCandidateIssue; copy the real assignees through when the
195205 // hosted contract carried them (#7442), falling back to [] only when the served response genuinely omitted the
@@ -203,6 +213,7 @@ function parseRepoTarget(value: string): FanoutTarget | null {
203213 const trimmed = value . trim ( ) ;
204214 const [ owner , repo , extra ] = trimmed . split ( "/" ) ;
205215 if ( ! owner || ! repo || extra !== undefined ) return null ;
216+ if ( ! isValidRepoSegment ( owner ) || ! isValidRepoSegment ( repo ) ) return null ;
206217 return { owner, repo } ;
207218}
208219
@@ -516,8 +527,17 @@ export async function runDiscover(args: string[], options: RunDiscoverOptions =
516527 let overrideLedger = null ;
517528 try {
518529 const ledgerEnv = options . env ?? process . env ;
519- overrideLedger = initEventLedger ( resolveEventLedgerDbPath ( ledgerEnv ) ) ;
520- minRankScore = readMinRankOverride ( overrideLedger , { enabled : readMinRankAutotuneEnabled ( ledgerEnv ) } ) ?? AMS_MIN_RANK_SHIPPED ;
530+ const ledgerDbPath = resolveEventLedgerDbPath ( ledgerEnv ) ;
531+ // #9679: --dry-run must make ZERO filesystem writes, but initEventLedger creates + migrates + prunes the
532+ // ledger file. On the dry-run path only read the override when the ledger file ALREADY exists (opening a
533+ // not-yet-existing SQLite file is itself a write, and retention pruning can delete rows) -- a missing file
534+ // falls back to the shipped default, exactly the value an empty/new ledger would yield, so the preview is
535+ // unchanged when it exists. Same "skip a file that doesn't exist yet" discipline as migrate-cli.ts /
536+ // store-maintenance.ts. The real (non-dry-run) run is unchanged: it opens unconditionally.
537+ if ( ! parsed . dryRun || existsSync ( ledgerDbPath ) ) {
538+ overrideLedger = initEventLedger ( ledgerDbPath ) ;
539+ minRankScore = readMinRankOverride ( overrideLedger , { enabled : readMinRankAutotuneEnabled ( ledgerEnv ) } ) ?? AMS_MIN_RANK_SHIPPED ;
540+ }
521541 } catch {
522542 minRankScore = AMS_MIN_RANK_SHIPPED ;
523543 } finally {
0 commit comments