Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
"hookable": "catalog:",
"jsonc-parser": "catalog:",
"log-update": "catalog:deps",
"md4x": "catalog:",
"mdream": "catalog:",
"ofetch": "catalog:",
"oxc-parser": "catalog:deps",
Expand Down
12 changes: 12 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ catalog:
giget: ^3.2.0
hookable: ^6.1.1
jsonc-parser: ^3.3.1
md4x: ^0.0.25
mdream: ^1.2.2
ofetch: ^1.5.1
pathe: ^2.0.3
Expand Down
107 changes: 107 additions & 0 deletions scripts/guides-batch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/**
* Batch-generate migration guides for the curated package set.
*
* Usage:
* tsx scripts/guides-batch.ts [--all] [--limit N] [--concurrency N]
* [--out DIR] [--model M] [--force]
* [--packages FILE]
*
* Writes <out>/<slug>.json (GeneratedGuide) + <out>/<slug>.md per package and a
* <out>/_manifest.json summary. Re-runs skip already-generated guides unless
* --force. Designed to run locally; skilld.dev ingests the JSON.
*/
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { join } from 'pathe'
import { listCuratedPackages } from '../src/guides/curated.ts'
import { generateGuide } from '../src/guides/generate.ts'
import { PIPELINE_VERSION } from '../src/guides/version.ts'

function arg(name: string, fallback?: string): string | undefined {
const i = process.argv.indexOf(`--${name}`)
return i !== -1 && process.argv[i + 1] ? process.argv[i + 1] : fallback
}
function flag(name: string): boolean {
return process.argv.includes(`--${name}`)
}

/** Filesystem-safe slug: `@scope/name` β†’ `@scope__name`. */
function safeSlug(pkg: string): string {
return pkg.replace(/\//g, '__')
}

const all = flag('all')
const force = flag('force')
const limit = Number(arg('limit') ?? Infinity)
const out = arg('out') ?? '.guides-out'
const model = (arg('model') ?? 'sonnet') as any
const distillModel = arg('distill-model') as any
const packagesFile = arg('packages')

// Local Ollama serializes requests, so parallel workers give no speedup and can
// thrash VRAM β€” default to 1 for `ollama:` models, 3 for cloud APIs.
const isLocal = String(model).startsWith('ollama:')
const concurrency = Number(arg('concurrency') ?? (isLocal ? '1' : '3'))
// Local models (esp. prose repos with many flagged LLM calls) are slow; give
// them plenty of headroom per guide.
const timeout = Number(arg('timeout') ?? (isLocal ? '1200000' : '300000'))

const packages = (packagesFile
? readFileSync(packagesFile, 'utf8').split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('#'))
: listCuratedPackages({ all })
).slice(0, limit)

mkdirSync(out, { recursive: true })

interface Row { pkg: string, status: 'ok' | 'skip' | 'fail', version?: string, cost?: number, error?: string }
const rows: Row[] = []
let totalCost = 0
let cursor = 0

async function worker(id: number): Promise<void> {
while (cursor < packages.length) {
const pkg = packages[cursor++]!
const jsonPath = join(out, `${safeSlug(pkg)}.json`)

// Incremental: skip a guide only when its persisted pipeline stamp matches
// the current logic version. A version bump (bucketing/prompt) marks older
// guides stale β†’ they regenerate; everything else is skipped. --force redoes all.
if (!force && existsSync(jsonPath)) {
const stamp = JSON.parse(readFileSync(jsonPath, 'utf8'))?.pipelineVersion
if (stamp === PIPELINE_VERSION) {
rows.push({ pkg, status: 'skip' })
process.stderr.write(` [${id}] ‏ skip ${pkg} (current: ${stamp})\n`)
continue
}
process.stderr.write(` [${id}] ↻ stale ${pkg} (${stamp ?? 'unstamped'} β†’ ${PIPELINE_VERSION})\n`)
}

process.stderr.write(` [${id}] … gen ${pkg}\n`)
const result = await generateGuide(pkg, { model, distillModel, timeout }).catch(err => ({ ok: false as const, error: String(err?.message ?? err) }))

if (!result.ok) {
rows.push({ pkg, status: 'fail', error: result.error })
process.stderr.write(` [${id}] βœ— fail ${pkg}: ${result.error}\n`)
continue
}

const { guide } = result
writeFileSync(jsonPath, JSON.stringify(guide, null, 2))
writeFileSync(join(out, `${safeSlug(pkg)}.md`), guide.markdown)
totalCost += guide.cost ?? 0
const c = guide.counts
rows.push({ pkg, status: 'ok', version: guide.version, cost: guide.cost })
process.stderr.write(` [${id}] βœ“ ok ${pkg}@${guide.version} [${c.breaking}b/${c.features}f/${c.fixes}x/${c.improvements}i]${guide.cost ? ` ($${guide.cost.toFixed(3)})` : ''}\n`)
}
}

process.stderr.write(`Generating ${packages.length} guides (model ${model}${distillModel ? `, distill ${distillModel}` : ''}, concurrency ${concurrency}) β†’ ${out}\n\n`)
await Promise.all(Array.from({ length: Math.min(concurrency, packages.length) }, (_, i) => worker(i + 1)))

const ok = rows.filter(r => r.status === 'ok').length
const skip = rows.filter(r => r.status === 'skip').length
const fail = rows.filter(r => r.status === 'fail')
writeFileSync(join(out, '_manifest.json'), JSON.stringify({ generatedCount: ok, skipped: skip, failed: fail.length, totalCost, rows }, null, 2))

process.stderr.write(`\n── done ──\n ok=${ok} skip=${skip} fail=${fail.length} cost=$${totalCost.toFixed(2)}\n`)
if (fail.length)
process.stderr.write(` failures:\n${fail.map(f => ` - ${f.pkg}: ${f.error}`).join('\n')}\n`)
80 changes: 79 additions & 1 deletion src/core/semver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,21 @@
* Centralized so the loose flag stays consistent across the project.
*/

import { difference, isGreater, normalize } from 'verkit'
import { difference, getMajor, getPrerelease, isGreater, normalize } from 'verkit'

export interface DistTagVersion {
version: string
releasedAt?: string
}

export interface PickedTag {
/** dist-tag name, e.g. 'latest', 'beta', 'rc', 'next'. */
tag: string
version: string
releasedAt?: string
/** True when the picked version is a prerelease (beta/rc/etc). */
prerelease: boolean
}

/** Returns the cleaned version if valid semver, null otherwise. */
export function semverValid(v: string): string | null {
Expand All @@ -19,3 +33,67 @@ export function semverGt(a: string, b: string): boolean {
export function semverDiff(a: string, b: string): string | null {
return difference(a, b)
}

/** Major version number (e.g. `9.2.2` β†’ 9, `1.0.0-rc.3` β†’ 1), or null if invalid. */
export function semverMajor(v: string): number | null {
const clean = semverValid(v)
return clean ? getMajor(clean) : null
}

/** True if `v` carries a prerelease component (e.g. 1.0.0-beta.8). */
export function semverIsPrerelease(v: string): boolean {
const clean = semverValid(v)
return clean ? (getPrerelease(clean)?.length ?? 0) > 0 : false
}

/** Trailing git short-hash, optionally `g`-prefixed: `-5d5b77c`, `-gabc1234`. */
const SNAPSHOT_RE = /-g?[0-9a-f]{7,40}$/i

/**
* True for per-commit snapshot publishes (e.g. drizzle's `1.0.0-rc.4-5d5b77c`).
* These get published under throwaway CI branch dist-tags and should not be the
* canonical version a guide targets.
*/
export function isSnapshotVersion(v: string): boolean {
return SNAPSHOT_RE.test(v)
}

/**
* Pick the largest version across all npm dist-tags, including prereleases.
*
* npm publishes prereleases under tags like `beta`/`rc`/`next` while `latest`
* stays on the last stable. semver ranks `1.0.0-beta.8 > 0.44.7`, so the max
* naturally surfaces the bleeding-edge release we want to index (the drizzle
* `1.0.0-beta.8` case). Ties resolve to the `latest` tag, then alphabetically
* for determinism. Invalid versions are skipped; returns null if none valid.
*/
export function pickLatestTag(distTags: Record<string, DistTagVersion> | undefined): PickedTag | null {
if (!distTags)
return null

const valid = Object.entries(distTags)
.filter(([, info]) => info?.version && semverValid(info.version))

if (!valid.length)
return null

// Prefer clean releases; fall back to snapshots only if that's all there is.
const clean = valid.filter(([, info]) => !isSnapshotVersion(info.version))
const candidates = clean.length ? clean : valid

let [bestTag, bestInfo] = candidates[0]!
for (const [tag, info] of candidates.slice(1)) {
if (semverGt(info.version, bestInfo.version)
|| (info.version === bestInfo.version && tag === 'latest')) {
bestTag = tag
bestInfo = info
}
}

return {
tag: bestTag,
version: bestInfo.version,
releasedAt: bestInfo.releasedAt,
prerelease: semverIsPrerelease(bestInfo.version),
}
}
Loading
Loading