cc-haha/scripts/quality-gate/providerTargets.ts
程序员阿江(Relakkes) 5f59c693c4 Let contributors choose quality-gate providers
The live baseline previously accepted provider UUIDs, which made the gate hard to run on another contributor's machine. Add a local provider listing command and resolve quality-gate targets from stable provider-name selectors while keeping UUIDs and current runtime support.

Constraint: Provider configuration is local machine state under CLAUDE_CONFIG_DIR and must not expose API keys.

Rejected: Require contributors to inspect providers.json manually | too error-prone and leaks implementation detail into the workflow

Confidence: high

Scope-risk: narrow

Directive: Keep live-provider baseline selection copyable from quality:providers before adding more live test lanes.

Tested: bun test scripts/quality-gate/providerTargets.test.ts

Tested: bun test scripts/quality-gate/*.test.ts scripts/quality-gate/baseline/*.test.ts

Tested: bun run quality:providers

Tested: bun run quality:gate --mode baseline --dry-run --provider-model codingplan:main --provider-model minimax:main

Tested: bun run quality:gate --mode baseline --dry-run --provider-model custom:haiku

Tested: bun run quality:gate --mode pr --dry-run

Tested: bun run check:server
2026-05-02 15:35:59 +08:00

258 lines
7.8 KiB
TypeScript

import { existsSync, readFileSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
import type { BaselineTarget } from './types'
type ProviderModels = {
main?: string
haiku?: string
sonnet?: string
opus?: string
}
type SavedProviderIndexEntry = {
id: string
name: string
presetId?: string
models?: ProviderModels
}
export type ProviderIndex = {
activeId: string | null
providers: SavedProviderIndexEntry[]
}
const DEFAULT_INDEX: ProviderIndex = { activeId: null, providers: [] }
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
export function getProviderIndexPath(configDir = process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude')) {
return join(configDir, 'cc-haha', 'providers.json')
}
export function slugifyProviderName(value: string) {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
}
function slugifyLabel(value: string) {
return value
.trim()
.replace(/[^a-zA-Z0-9._-]+/g, '-')
.replace(/^-+|-+$/g, '')
}
function providerAliases(provider: SavedProviderIndexEntry) {
return new Set([
provider.id,
provider.name,
provider.name.toLowerCase(),
slugifyProviderName(provider.name),
].filter(Boolean))
}
function modelEntries(provider: SavedProviderIndexEntry) {
const roles = [
['main', provider.models?.main],
['haiku', provider.models?.haiku],
['sonnet', provider.models?.sonnet],
['opus', provider.models?.opus],
] as const
const seen = new Set<string>()
return roles
.filter((entry): entry is [typeof entry[0], string] => typeof entry[1] === 'string' && entry[1].trim().length > 0)
.filter(([, modelId]) => {
if (seen.has(modelId)) return false
seen.add(modelId)
return true
})
}
function splitProviderTarget(value: string) {
const separator = value.indexOf(':')
if (separator === -1) {
return null
}
return {
providerRef: value.slice(0, separator),
modelAndLabel: value.slice(separator + 1),
}
}
function splitModelAndLabel(value: string) {
const separator = value.lastIndexOf(':')
if (separator === -1) {
return { modelId: value, label: undefined }
}
return {
modelId: value.slice(0, separator),
label: value.slice(separator + 1) || undefined,
}
}
function resolveProviderModel(provider: SavedProviderIndexEntry, modelAndLabel: string) {
const roleEntries = [
['main', provider.models?.main],
['haiku', provider.models?.haiku],
['sonnet', provider.models?.sonnet],
['opus', provider.models?.opus],
] as const
for (const [role, modelId] of roleEntries) {
if (!modelId) continue
if (modelAndLabel === role) {
return { modelId, label: undefined }
}
if (modelAndLabel.startsWith(`${role}:`)) {
return { modelId, label: modelAndLabel.slice(role.length + 1) || undefined }
}
}
const knownModels = [...new Set(roleEntries.map(([, modelId]) => modelId).filter((modelId): modelId is string => Boolean(modelId)))]
.sort((left, right) => right.length - left.length)
for (const modelId of knownModels) {
if (modelAndLabel === modelId) {
return { modelId, label: undefined }
}
if (modelAndLabel.startsWith(`${modelId}:`)) {
return { modelId, label: modelAndLabel.slice(modelId.length + 1) || undefined }
}
}
return splitModelAndLabel(modelAndLabel)
}
export function loadProviderIndex(configDir?: string): ProviderIndex {
const filePath = getProviderIndexPath(configDir)
if (!existsSync(filePath)) {
return { ...DEFAULT_INDEX, providers: [] }
}
const parsed = JSON.parse(readFileSync(filePath, 'utf8')) as Partial<ProviderIndex>
return {
activeId: typeof parsed.activeId === 'string' ? parsed.activeId : null,
providers: Array.isArray(parsed.providers)
? parsed.providers.filter((provider): provider is SavedProviderIndexEntry => (
provider &&
typeof provider.id === 'string' &&
typeof provider.name === 'string'
))
: [],
}
}
function resolveProviderReference(reference: string, index: ProviderIndex) {
if (reference === 'current' || reference === 'official') {
return null
}
const normalized = reference.toLowerCase()
const slug = slugifyProviderName(reference)
const matches = index.providers.filter((provider) => {
const aliases = providerAliases(provider)
return aliases.has(reference) || aliases.has(normalized) || aliases.has(slug)
})
if (matches.length === 1) {
return matches[0]
}
if (matches.length > 1) {
throw new Error(`Ambiguous provider reference "${reference}". Use one of these IDs: ${matches.map((provider) => provider.id).join(', ')}`)
}
if (UUID_PATTERN.test(reference)) {
return { id: reference, name: reference }
}
throw new Error(`Unknown provider "${reference}". Run "bun run quality:providers" to list available --provider-model values.`)
}
export function parseBaselineTargetValues(values: string[], index: ProviderIndex = loadProviderIndex()): BaselineTarget[] {
return values
.flatMap((value) => value.split(','))
.map((value) => value.trim())
.filter(Boolean)
.map((value) => {
const target = splitProviderTarget(value)
if (!target?.providerRef || !target.modelAndLabel) {
throw new Error(`Invalid --provider-model value "${value}". Expected provider:model[:label]. Run "bun run quality:providers" for copyable values.`)
}
const { providerRef, modelAndLabel } = target
const provider = resolveProviderReference(providerRef, index)
if (!provider) {
const { modelId, label } = splitModelAndLabel(modelAndLabel)
return {
providerId: null,
modelId,
label: label || (providerRef === 'current' && modelId === 'current' ? 'current-runtime' : `${providerRef}-${modelId}`),
}
}
const { modelId, label } = resolveProviderModel(provider, modelAndLabel)
const providerSlug = slugifyProviderName(provider.name) || provider.id.slice(0, 8)
return {
providerId: provider.id,
modelId,
label: label || slugifyLabel(`${providerSlug}-${modelId}`),
}
})
}
function providerSelector(provider: SavedProviderIndexEntry, index: ProviderIndex) {
const slug = slugifyProviderName(provider.name)
if (!slug) {
return provider.id
}
const matchingSlugCount = index.providers.filter((candidate) => slugifyProviderName(candidate.name) === slug).length
return matchingSlugCount === 1 ? slug : provider.id
}
export function formatProviderTargets(index: ProviderIndex = loadProviderIndex(), configDir = process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude')) {
const lines = [
'Quality gate provider targets',
`Config: ${getProviderIndexPath(configDir)}`,
'',
'Current/default runtime:',
' --provider-model current:current:current-runtime',
'',
]
if (index.providers.length === 0) {
lines.push('Saved providers: none')
lines.push('')
lines.push('Add a provider in Desktop Settings > Providers, then run this command again.')
return lines.join('\n')
}
lines.push('Saved providers:')
for (const provider of index.providers) {
const providerSlug = providerSelector(provider, index)
const active = index.activeId === provider.id ? ' (active)' : ''
lines.push(` ${provider.name}${active}`)
lines.push(` id: ${provider.id}`)
lines.push(` selector: ${providerSlug}`)
const models = modelEntries(provider)
if (models.length === 0) {
lines.push(' models: none configured')
continue
}
for (const [role, modelId] of models) {
const label = slugifyLabel(`${providerSlug}-${role}`)
lines.push(` ${role}: ${modelId}`)
lines.push(` --provider-model ${providerSlug}:${role}:${label}`)
}
}
return lines.join('\n')
}