diff --git a/docs/release-convention.md b/docs/release-convention.md new file mode 100644 index 00000000..6dd05556 --- /dev/null +++ b/docs/release-convention.md @@ -0,0 +1,49 @@ +# Release Convention + +## Tag format + +``` +/vX.Y.Z # monorepo subdir package (e.g. roxabi-nats/v1.2.3) +vX.Y.Z # single-package repo (e.g. v0.5.0) +``` + +PRs: merge-commit only (¬squash) — squash causes history divergence on next promotion. + +## Branch convention for uv git deps + +Roxabi Python repos consume cross-repo deps via `[tool.uv.sources]` in `pyproject.toml`. + +| Branch | Ref style | When | +|--------|-----------|------| +| `staging` | `branch = "staging"` | Development — tracks latest staging SHA | +| `main` | `tag = "vX.Y.Z"` | Production — pinned to exact release tag | + +This means `pyproject.toml` on `staging` uses `branch=`, and on `main` uses `tag=`. The swap is automated by `/promote` (Step 1b — pin-swap phase). + +## `/promote` pin-swap phase + +At promotion time (staging→main), `/promote` automatically: + +1. Detects `[tool.uv.sources]` entries with `branch=` +2. Resolves the SHA pinned in `uv.lock` to a release tag on the remote (`git ls-remote --tags`) +3. Shows a DP(A) diff: `branch=staging → tag=vX.Y.Z` +4. On Apply: rewrites `pyproject.toml`, regenerates `uv.lock`, stages both + +If no release tag exists at the locked SHA, promotion FAILS with: + +``` +FAIL: No release tag found at @. +Cut a release tag (e.g. /vX.Y.Z) at upstream first. +``` + +This is intentional friction — promotion must ship exactly what staging tested. + +## Scope + +uv-only (`[tool.uv.sources]`). pip / poetry / pnpm deferred until a real consumer appears. + +## References + +- `/promote` SKILL.md — Step 1b full spec +- `lib/pin-swap.ts` — implementation (pure functions, I/O-injected) +- `__tests__/pin-swap.test.ts` — unit tests diff --git a/plugins/dev-core/skills/frame/README.md b/plugins/dev-core/skills/frame/README.md index 70653a30..d49ced6a 100644 --- a/plugins/dev-core/skills/frame/README.md +++ b/plugins/dev-core/skills/frame/README.md @@ -19,10 +19,15 @@ Triggers: `"frame"` | `"frame this"` | `"what's the problem"` | `"define the pro 1. **Parse + Seed** — reads the GitHub issue (title, body, labels) or free text as context. 2. **Interview** — asks 3–5 focused questions (skips what's already clear from the issue body): problem/pain, affected users, constraints, out-of-scope, related work. -3. **Tier detection** — infers S / F-lite / F-full from complexity signals (file count, domain breadth, unknowns); lets you override. -4. **Write frame doc** — creates `artifacts/frames/{N}-{slug}-frame.mdx` with status: `draft`. -5. **User approval** — presents the frame for confirmation; loops on revisions until approved. -6. **Commit + status update** — sets issue status to `Analysis` and commits the artifact. +3. **Premise-validity gate** — required before tier classification. Captures three fields: + - `success_in_6mo` — what does success look like? (concrete, observable) + - `failure_in_6mo` — what does failure look like? (must be falsifiable) + - `simplest_alternative` + why it's insufficient — forces explicit comparison against the minimal solution + Cannot proceed without all three. Non-falsifiable failure modes trigger an abort prompt. +4. **Tier detection** — infers S / F-lite / F-full from complexity signals (file count, domain breadth, unknowns); lets you override. +5. **Write frame doc** — creates `artifacts/frames/{N}-{slug}-frame.mdx` with status: `draft`. +6. **User approval** — presents the frame for confirmation; loops on revisions until approved. +7. **Commit + status update** — sets issue status to `Analysis` and commits the artifact. ## Output artifact @@ -30,7 +35,7 @@ Triggers: `"frame"` | `"frame this"` | `"what's the problem"` | `"define the pro artifacts/frames/{N}-{slug}-frame.mdx ``` -Fields: `title`, `issue`, `status: approved`, `tier`, `date`, Problem, Who, Constraints, Out of Scope, Complexity. +Fields: `title`, `issue`, `status: approved`, `tier`, `date`, Problem, Who, Constraints, Out of Scope, Premise Validity (required: `success_in_6mo`, `failure_in_6mo`, `simplest_alternative` + why-not), Complexity. ## Chain position diff --git a/plugins/dev-core/skills/frame/SKILL.md b/plugins/dev-core/skills/frame/SKILL.md index 31fab401..c531ecab 100644 --- a/plugins/dev-core/skills/frame/SKILL.md +++ b/plugins/dev-core/skills/frame/SKILL.md @@ -2,7 +2,7 @@ name: frame argument-hint: '["idea" | --issue ]' description: Problem framing — capture problem, constraints, scope, tier. Triggers: "frame" | "frame this" | "what's the problem" | "define the problem" | "scope this out" | "define the scope" | "what are we solving" | "help me think through this problem" | "problem statement". -version: 0.2.0 +version: 0.3.0 allowed-tools: Bash, Read, Write, Edit, Glob, Grep, ToolSearch --- @@ -35,6 +35,7 @@ Standalone-safe: callable without `/dev`. Output consumed by `/analyze`, `/spec` |------|----|----------|---------------|-------| | 0 | parse | ✓ | `gh issue view N` → JSON | — | | 1 | interview | — | — | 3–5 Q max | +| 1b | premise | ✓ | 3 fields non-empty | **gate** — blocks Step 2 | | 2 | tier | ✓ | τ ∈ frontmatter | — | | 3 | write | ✓ | φ ∃ | — | | 4 | approval | ✓ | `status: approved` | gate | @@ -43,7 +44,7 @@ Standalone-safe: callable without `/dev`. Output consumed by `/analyze`, `/spec` Success: φ written ∧ status: approved Evidence: `ls artifacts/frames/` after execution -Steps: parse → interview → tier → write → approval +Steps: parse → interview → premise-gate → tier → write → approval ¬clear → STOP + ask: "What problem are you solving?" ## Step 0 — Parse + Seed @@ -79,6 +80,28 @@ Check ∃ φ: ¬ask all 5 if seed is rich — ask only what's missing. +## Step 1b — Premise-Validity Gate + +**Gate: cannot proceed to Step 2 without all 3 fields answered.** + +Capture in a single AQ (present all 3 together): + +| Field | Prompt | Requirement | +|-------|--------|-------------| +| `success_in_6mo` | "What does success look like in 6 months?" | Concrete, observable outcome — ¬vague ("things are better") | +| `failure_in_6mo` | "What does failure look like in 6 months?" | Must be **falsifiable** — a condition you could actually observe ∧ decide to abort | +| `simplest_alternative` | "What's the simplest version that would meet the goal — and why isn't it enough?" | Forces explicit comparison; the "why not" is required, ¬optional | + +Evaluation rules: + +- `failure_in_6mo` ¬falsifiable (e.g. "people aren't happy") → reject, re-ask. Example of falsifiable: "DEBT count stays flat or rises despite 3 sprint cycles." Example of non-falsifiable: "the team doesn't feel better." +- `simplest_alternative` answer omits the "why not" half → re-ask: "You described the simpler version — why won't it be enough?" +- Any field empty or answered with ≤5 words → treat as unanswered, re-ask. + +**Abort signal:** if the user answers `failure_in_6mo` with a description that matches "we'd still have the problem but with extra bookkeeping" (i.e. the initiative measures proxy metrics, ¬the underlying issue) → surface: "This failure mode suggests the premise may be invalid. Do you want to reframe the problem or abort?" AQ: **Reframe** | **Abort**. + +Origin: pattern surfaced by Roxabi/lyra#1162 — quality-debt annotation infrastructure (~1100 LOC + 6 registry files) where the ratchet measured bookkeeping compliance, not code quality. A falsification check at /frame would have caught this. + ## Step 2 — Tier Detection Auto-detect τ from complexity signals: @@ -126,6 +149,17 @@ date: {YYYY-MM-DD} - {explicit non-goals as bullets} +## Premise Validity + +**Required — populated from Step 1b. ¬leave blank.** + +**Success in 6 months:** {concrete, observable outcome} + +**Failure in 6 months:** {falsifiable condition — observable ∧ actionable} + +**Simplest alternative:** {minimal version that meets the goal} +**Why not simplest:** {explicit reason the simpler path is insufficient} + ## Complexity **Tier: {τ}** — {1-sentence rationale} diff --git a/plugins/dev-core/skills/plan/SKILL.md b/plugins/dev-core/skills/plan/SKILL.md index 0f546082..b5cf4d55 100644 --- a/plugins/dev-core/skills/plan/SKILL.md +++ b/plugins/dev-core/skills/plan/SKILL.md @@ -90,6 +90,21 @@ Intra-domain parallel: ≥4 independent tasks in 1 domain → multiple same-type **2d. Tasks:** ∀ task: description, files, agent, dependencies, parallel-safe (Y/N). Order: types → backend → frontend → tests → docs → config. +**Budget heuristic (ops estimate):** After listing tasks, classify each by cost class and compute estimated tool-call ops. Record in the plan artifact's Wave Structure section as a Budget Table. + +Cost classes: + +| Class | Ops/item | Examples | +|-------|----------|---------| +| `trivial` | 1–2 | string replace, single grep | +| `bounded` | 2–3 | read + edit known file | +| `judgmental` | 4–6 | read + context + judge + edit | +| `exploratory` | 8–15 | open-ended cross-file search | + +Rule: if `estimated_total_ops > 50` for a task → **force-split** the task into smaller sub-tasks, or present a DP(A) **Split now** | **Keep as-is (flag)** decision before proceeding. + +Implementation helper: `plugins/dev-core/skills/plan/lib/budget.ts` — exports `classifyTask`, `computeBudget`, `renderBudgetTable`. + **2e. Slice Selection (multi-slice only):** ≥2 slices → → DP(C) 1 option/slice `V{N}: {desc} ({files}, {agents})`. Default: next unimplemented slice. Respect deps. Re-run `/plan` for remaining. @@ -178,6 +193,21 @@ After micro-tasks, derive waves from the dependency graph. Name parallel agent i | 2 | Wave 1 done | {K} ∥ | ... | ``` +After the wave table, include a **Budget Table** derived from Step 2d classification: + +```markdown +### Budget + +| Task | Items | Class | Est. ops | Split? | +|------|-------|-------|----------|--------| +| {task name} | {N} | {class} | {ops} | — | +| {large task} | {N} | exploratory | {ops} | YES — split required | + +**Total estimated ops: {total}** +``` + +Tasks marked `YES — split required` must be resolved (split or DP-approved) before the plan is finalized. + Rules: - Wave 1 = all tasks with no deps. - Wave N = tasks whose deps are all in earlier waves. diff --git a/plugins/dev-core/skills/plan/__tests__/budget.test.ts b/plugins/dev-core/skills/plan/__tests__/budget.test.ts new file mode 100644 index 00000000..b9d9e20e --- /dev/null +++ b/plugins/dev-core/skills/plan/__tests__/budget.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from 'vitest' +import { classifyTask, computeBudget, renderBudgetTable, SPLIT_THRESHOLD } from '../lib/budget' + +describe('budget classifier', () => { + describe('classifyTask', () => { + it('trivial: 1 item → 2 ops (rounded mid 1.5)', () => { + const row = classifyTask({ name: 'Fix typo', items: 1, costClass: 'trivial' }) + expect(row.estimatedOps).toBe(2) + expect(row.mustSplit).toBe(false) + }) + + it('bounded: 3 items → 8 ops (3 * 2.5 = 7.5 → 8)', () => { + const row = classifyTask({ name: 'Edit config files', items: 3, costClass: 'bounded' }) + expect(row.estimatedOps).toBe(8) + expect(row.mustSplit).toBe(false) + }) + + it('judgmental: 6 items → 30 ops (6 * 5)', () => { + const row = classifyTask({ name: 'Review route handlers', items: 6, costClass: 'judgmental' }) + expect(row.estimatedOps).toBe(30) + expect(row.mustSplit).toBe(false) + }) + + it('exploratory: 5 items → 58 ops (5 * 11.5 = 57.5 → 58) — exceeds threshold', () => { + const row = classifyTask({ name: 'Audit cross-file deps', items: 5, costClass: 'exploratory' }) + expect(row.estimatedOps).toBe(58) + expect(row.mustSplit).toBe(true) + }) + + it('mustSplit is false at exactly the threshold', () => { + // judgmental: 10 items * 5 mid = 50 — NOT > 50, no split + const row = classifyTask({ name: 'Exactly at threshold', items: 10, costClass: 'judgmental' }) + expect(row.estimatedOps).toBe(50) + expect(row.mustSplit).toBe(false) + }) + + it('mustSplit is true one item above the threshold boundary', () => { + // judgmental: 11 items * 5 = 55 — > 50, split required + const row = classifyTask({ name: 'Just over threshold', items: 11, costClass: 'judgmental' }) + expect(row.estimatedOps).toBe(55) + expect(row.mustSplit).toBe(true) + }) + + it('preserves name and items in output', () => { + const row = classifyTask({ name: 'My task', items: 4, costClass: 'bounded' }) + expect(row.name).toBe('My task') + expect(row.items).toBe(4) + expect(row.costClass).toBe('bounded') + }) + }) + + describe('computeBudget', () => { + it('totals ops across all tasks', () => { + const { rows, totalOps } = computeBudget([ + { name: 'T1', items: 2, costClass: 'trivial' }, // 2 * 1.5 = 3 → 3 + { name: 'T2', items: 4, costClass: 'bounded' }, // 4 * 2.5 = 10 + ]) + expect(rows).toHaveLength(2) + expect(totalOps).toBe(rows.reduce((s, r) => s + r.estimatedOps, 0)) + }) + + it('returns empty rows and 0 total for empty input', () => { + const { rows, totalOps } = computeBudget([]) + expect(rows).toHaveLength(0) + expect(totalOps).toBe(0) + }) + + it('flags tasks that individually exceed the threshold', () => { + const { rows } = computeBudget([ + { name: 'Big task', items: 6, costClass: 'exploratory' }, // 6 * 11.5 = 69 → mustSplit + { name: 'Small task', items: 2, costClass: 'bounded' }, // 2 * 2.5 = 5 → fine + ]) + expect(rows[0].mustSplit).toBe(true) + expect(rows[1].mustSplit).toBe(false) + }) + }) + + describe('renderBudgetTable', () => { + it('includes header and separator rows', () => { + const rows = [classifyTask({ name: 'T1', items: 2, costClass: 'bounded' })] + const output = renderBudgetTable(rows) + expect(output).toContain('| Task | Items | Class | Est. ops | Split? |') + expect(output).toContain('|------|-------|-------|----------|--------|') + }) + + it('shows — for tasks that do not need splitting', () => { + const rows = [classifyTask({ name: 'Small task', items: 1, costClass: 'trivial' })] + const output = renderBudgetTable(rows) + expect(output).toContain('| — |') + }) + + it('shows YES — split required for tasks over the threshold', () => { + const rows = [classifyTask({ name: 'Big task', items: 6, costClass: 'exploratory' })] + const output = renderBudgetTable(rows) + expect(output).toContain('YES — split required') + }) + + it('includes total ops footer', () => { + const rows = [ + classifyTask({ name: 'T1', items: 2, costClass: 'bounded' }), + classifyTask({ name: 'T2', items: 1, costClass: 'trivial' }), + ] + const output = renderBudgetTable(rows) + const total = rows.reduce((s, r) => s + r.estimatedOps, 0) + expect(output).toContain(`**Total estimated ops: ${total}**`) + }) + + it('renders all tasks as table rows', () => { + const inputs = [ + { name: 'Alpha', items: 3, costClass: 'bounded' as const }, + { name: 'Beta', items: 2, costClass: 'judgmental' as const }, + ] + const { rows } = computeBudget(inputs) + const output = renderBudgetTable(rows) + expect(output).toContain('Alpha') + expect(output).toContain('Beta') + }) + + it('SPLIT_THRESHOLD constant is 50', () => { + expect(SPLIT_THRESHOLD).toBe(50) + }) + }) +}) diff --git a/plugins/dev-core/skills/plan/lib/budget.ts b/plugins/dev-core/skills/plan/lib/budget.ts new file mode 100644 index 00000000..a9b734f9 --- /dev/null +++ b/plugins/dev-core/skills/plan/lib/budget.ts @@ -0,0 +1,100 @@ +/** + * Task budget classifier for /plan Step 2d. + * + * Each task is assigned a cost class based on the nature of its work. + * The class drives an ops-range estimate used to flag tasks that exceed + * the 50-op threshold and require splitting or a DP decision. + */ + +export type CostClass = 'trivial' | 'bounded' | 'judgmental' | 'exploratory' + +export interface OpsRange { + min: number + max: number +} + +/** Mid-point estimate used for total roll-up. */ +export const OPS_MID: Record = { + trivial: 1.5, + bounded: 2.5, + judgmental: 5, + exploratory: 11.5, +} + +export const OPS_RANGE: Record = { + trivial: { min: 1, max: 2 }, + bounded: { min: 2, max: 3 }, + judgmental: { min: 4, max: 6 }, + exploratory: { min: 8, max: 15 }, +} + +export interface TaskBudgetInput { + /** Short task description or title. */ + name: string + /** Number of discrete items (files, patterns, criteria) the task covers. */ + items: number + /** Cost class for one item in the task. */ + costClass: CostClass +} + +export interface TaskBudgetRow { + name: string + items: number + costClass: CostClass + /** Estimated total ops = items * mid-point for the class. */ + estimatedOps: number + /** Whether this task exceeds the 50-op threshold and must be split or flagged. */ + mustSplit: boolean +} + +export const SPLIT_THRESHOLD = 50 + +/** + * Classify a single task and compute its budget row. + */ +export function classifyTask(input: TaskBudgetInput): TaskBudgetRow { + const estimatedOps = Math.round(input.items * OPS_MID[input.costClass]) + return { + name: input.name, + items: input.items, + costClass: input.costClass, + estimatedOps, + mustSplit: estimatedOps > SPLIT_THRESHOLD, + } +} + +/** + * Compute budget rows for a list of tasks and return the grand total. + */ +export function computeBudget(inputs: TaskBudgetInput[]): { + rows: TaskBudgetRow[] + totalOps: number +} { + const rows = inputs.map(classifyTask) + const totalOps = rows.reduce((sum, r) => sum + r.estimatedOps, 0) + return { rows, totalOps } +} + +/** + * Render the budget table as a Markdown string for inclusion in the plan + * artifact's Wave Structure section. + * + * Example output: + * + * | Task | Items | Class | Est. ops | Split? | + * |------|-------|-------|----------|--------| + * | Add classifier | 3 | bounded | 8 | — | + * | Audit all routes | 12 | exploratory | 138 | YES — split required | + * + * **Total estimated ops: 146** + */ +export function renderBudgetTable(rows: TaskBudgetRow[]): string { + const header = '| Task | Items | Class | Est. ops | Split? |' + const sep = '|------|-------|-------|----------|--------|' + const dataRows = rows.map((r) => { + const split = r.mustSplit ? 'YES — split required' : '—' + return `| ${r.name} | ${r.items} | ${r.costClass} | ${r.estimatedOps} | ${split} |` + }) + const totalOps = rows.reduce((sum, r) => sum + r.estimatedOps, 0) + return [header, sep, ...dataRows, '', `**Total estimated ops: ${totalOps}**`].join('\n') +} diff --git a/plugins/dev-core/skills/plan/references/plan-template.mdx b/plugins/dev-core/skills/plan/references/plan-template.mdx index 805cb275..ebbfc51d 100644 --- a/plugins/dev-core/skills/plan/references/plan-template.mdx +++ b/plugins/dev-core/skills/plan/references/plan-template.mdx @@ -80,6 +80,14 @@ flowchart LR | 1 | start | {K} ∥ | {agent-A}: T{n} · {agent-B}: T{m} | | 2 | Wave 1 done | {K} ∥ | {agent}: T{n}+T{m} | +### Budget + +| Task | Items | Class | Est. ops | Split? | +|------|-------|-------|----------|--------| +| {task name} | {N} | {class} | {ops} | — | + +**Total estimated ops: {total}** + ## Consistency Report - Criteria covered: {N}/{total} diff --git a/plugins/dev-core/skills/promote/README.md b/plugins/dev-core/skills/promote/README.md index e28bc253..86e3184e 100644 --- a/plugins/dev-core/skills/promote/README.md +++ b/plugins/dev-core/skills/promote/README.md @@ -20,6 +20,7 @@ Triggers: `"promote staging"` | `"release"` | `"deploy"` | `"cut a release"` | ` ## How it works 1. **Pre-flight** — checks commits ahead of main, open PRs on staging, CI status. Refuses if nothing to promote. +1b. **Pin-swap** — detects `branch=` git deps in `[tool.uv.sources]`, resolves each to a release tag at the locked SHA, rewrites `pyproject.toml`, regenerates `uv.lock`. No-op if zero `branch=` git deps. Fails loud if SHA has no matching tag (forces upstream to cut a release first). 2. **Version + changelog** — bumps version, writes changelog (see `references/release-artifacts.md`). 3. **Deploy preview** (optional) — triggers `deploy-preview.yml` workflow and waits for it. 4. **Summary** — shows version, commit count, file count, CI status, preview result. diff --git a/plugins/dev-core/skills/promote/SKILL.md b/plugins/dev-core/skills/promote/SKILL.md index 0069b7b8..869d4fb4 100644 --- a/plugins/dev-core/skills/promote/SKILL.md +++ b/plugins/dev-core/skills/promote/SKILL.md @@ -23,6 +23,7 @@ Let: σ := staging | μ := main | V := release version (vX.Y.Z) | Q := DP(A) | Step | ID | Required | Verifies via | Notes | |------|----|----------|---------------|-------| | 1 | pre-flight | ✓ | ¬REFUSE | — | +| 1b | pin-swap | — | ¬branch= deps remain | no-op if zero branch= deps | | 2 | version | ✓ | V detected | — | | 3 | changelog | ✓ | CHANGELOG.md updated | — | | 4 | commit | ✓ | `git log` shows commit | — | @@ -63,6 +64,81 @@ Emits: `commits_ahead`, `status`, commit log, diff stat, open PRs on staging, CI | Open PRs on σ | open_prs section non-empty | **WARN** + Q: **Continue** \| **Wait** | | CI status | ci section | **WARN** if ¬passing | +## Step 1b — Pin-swap Phase + +Runs after pre-flight, before version bump. Rewrites mutable `branch=` git deps in `[tool.uv.sources]` to immutable `tag=` pins for the promotion commit. + +**Trigger:** `pyproject.toml` exists AND `[tool.uv.sources]` contains at least one entry with `branch=`. + +**No-op:** if zero `branch=` git deps found → silent skip, continue to Step 2. + +### Detection + +Scan `pyproject.toml` `[tool.uv.sources]`: + +```toml +# Detected — has branch= +roxabi-nats = { git = "https://github.com/Roxabi/roxabi-nats", branch = "staging" } + +# Ignored — already pinned +roxabi-nats = { git = "https://github.com/Roxabi/roxabi-nats", tag = "v1.2.3" } +``` + +### Resolution + +For each detected dep: +1. Read pinned SHA from `uv.lock` (`rev = ""` in package source) +2. Run `git ls-remote --tags ` on the remote +3. Match SHA → release tag at that exact commit +4. Tag matching: prefer `/vX.Y.Z` (monorepo subdirectory style), fall back to bare `vX.Y.Z` + +### DP(A) gate + +``` +── Decision: Pin uv git deps ── +Context: N branch= git deps found; will be rewritten for promotion +Target: Immutable tag pins in pyproject.toml before staging→main +Path: Rewrite pyproject.toml, run uv lock, stage both files + +Deps: + - roxabi-nats: branch=staging → tag=roxabi-nats/v1.2.3 (SHA: abc123def456) + +Options: + 1. Apply — rewrite + regenerate uv.lock + stage + 2. Abort — stop promotion, no changes +Recommended: Option 1 +``` + +### On Apply + +```bash +# Rewrite pyproject.toml (branch= → tag=) for each dep +# Then regenerate: +uv lock +git add pyproject.toml uv.lock +``` + +### On Abort + +Revert `pyproject.toml` to original (no changes were written). Stop promotion. + +### Error: no tag at SHA + +``` +FAIL: No release tag found at roxabi-nats@abc123def4 on https://github.com/Roxabi/roxabi-nats. +Cut a release tag (e.g. roxabi-nats/vX.Y.Z) at abc123def4 upstream first. +``` + +Stops promotion. User must cut a tag upstream before retrying. + +### `--dry-run` + +Show pin-swap plan (deps + resolved tags) but do NOT write files. Continue to show version/changelog summary, then stop. + +### Implementation + +Logic lives in `lib/pin-swap.ts` (pure functions, I/O-injected). Tests in `__tests__/pin-swap.test.ts`. + ## Steps 2-4 — Version, Changelog, Commit Read [references/release-artifacts.md](${CLAUDE_SKILL_DIR}/references/release-artifacts.md) for full procedure. diff --git a/plugins/dev-core/skills/promote/__tests__/pin-swap.test.ts b/plugins/dev-core/skills/promote/__tests__/pin-swap.test.ts new file mode 100644 index 00000000..f76deac8 --- /dev/null +++ b/plugins/dev-core/skills/promote/__tests__/pin-swap.test.ts @@ -0,0 +1,460 @@ +import { describe, expect, it, vi } from 'vitest' +import { + applyPinSwap, + buildPinSwapPlan, + type Deps, + formatPlan, + type PinSwapPlan, + parseUvGitDeps, + readLockedSha, + resolveTagAtSha, + rewritePyproject, +} from '../lib/pin-swap' + +// ─── parseUvGitDeps ─────────────────────────────────────────────────────────── + +describe('parseUvGitDeps', () => { + it('returns empty array when no [tool.uv.sources] section', () => { + const toml = '[tool.poetry]\nname = "myapp"\n' + expect(parseUvGitDeps(toml)).toEqual([]) + }) + + it('returns empty array when sources section has no git entries', () => { + const toml = '[tool.uv.sources]\nlibA = { url = "https://example.com/lib.tar.gz" }\n' + expect(parseUvGitDeps(toml)).toEqual([]) + }) + + it('returns empty array when git source has no branch= (tag= instead)', () => { + const toml = '[tool.uv.sources]\nlibA = { git = "https://github.com/org/libA", tag = "v1.0.0" }\n' + expect(parseUvGitDeps(toml)).toEqual([]) + }) + + it('parses inline table with branch=', () => { + const toml = [ + '[tool.uv.sources]', + 'roxabi-nats = { git = "https://github.com/Roxabi/roxabi-nats", branch = "staging" }', + ].join('\n') + const result = parseUvGitDeps(toml) + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ + name: 'roxabi-nats', + source: { git: 'https://github.com/Roxabi/roxabi-nats', branch: 'staging' }, + }) + }) + + it('parses multiple inline table entries', () => { + const toml = [ + '[tool.uv.sources]', + 'libA = { git = "https://github.com/org/libA", branch = "staging" }', + 'libB = { git = "https://github.com/org/libB", branch = "dev" }', + ].join('\n') + const result = parseUvGitDeps(toml) + expect(result).toHaveLength(2) + expect(result.map((r) => r.name)).toEqual(['libA', 'libB']) + }) + + it('skips entries without branch= even when git= is present', () => { + const toml = [ + '[tool.uv.sources]', + 'libA = { git = "https://github.com/org/libA", rev = "abc123" }', + 'libB = { git = "https://github.com/org/libB", branch = "staging" }', + ].join('\n') + const result = parseUvGitDeps(toml) + expect(result).toHaveLength(1) + expect(result[0].name).toBe('libB') + }) + + it('parses sub-table form', () => { + const toml = [ + '[tool.uv.sources.roxabi-nats]', + 'git = "https://github.com/Roxabi/roxabi-nats"', + 'branch = "staging"', + ].join('\n') + const result = parseUvGitDeps(toml) + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ + name: 'roxabi-nats', + source: { git: 'https://github.com/Roxabi/roxabi-nats', branch: 'staging' }, + }) + }) + + it('stops parsing sources at the next unrelated section header', () => { + const toml = [ + '[tool.uv.sources]', + 'libA = { git = "https://github.com/org/libA", branch = "staging" }', + '[tool.ruff]', + 'libB = { git = "https://github.com/org/libB", branch = "staging" }', + ].join('\n') + const result = parseUvGitDeps(toml) + expect(result).toHaveLength(1) + expect(result[0].name).toBe('libA') + }) +}) + +// ─── readLockedSha ──────────────────────────────────────────────────────────── + +describe('readLockedSha', () => { + const uvLock = [ + '[[package]]', + 'name = "roxabi-nats"', + 'version = "0.2.0"', + 'source = { git = "https://github.com/Roxabi/roxabi-nats", rev = "abc123def456abc123def456abc123def456abc1" }', + '', + '[[package]]', + 'name = "other-lib"', + 'version = "1.0.0"', + 'source = { git = "https://github.com/org/other", rev = "111222333444111222333444111222333444abcd" }', + ].join('\n') + + it('returns SHA for a known package', () => { + const sha = readLockedSha(uvLock, 'roxabi-nats') + expect(sha).toBe('abc123def456abc123def456abc123def456abc1') + }) + + it('returns SHA for another package', () => { + const sha = readLockedSha(uvLock, 'other-lib') + expect(sha).toBe('111222333444111222333444111222333444abcd') + }) + + it('returns null for unknown package', () => { + expect(readLockedSha(uvLock, 'unknown-pkg')).toBeNull() + }) + + it('normalizes hyphens to underscores for matching', () => { + const lock = [ + '[[package]]', + 'name = "roxabi_nats"', + 'source = { git = "https://github.com/Roxabi/roxabi-nats", rev = "aaabbbcccdddeeefffaaabbbcccdddeeefffaaab" }', + ].join('\n') + expect(readLockedSha(lock, 'roxabi-nats')).toBe('aaabbbcccdddeeefffaaabbbcccdddeeefffaaab') + }) + + it('returns null when source has no rev field', () => { + const lock = ['[[package]]', 'name = "roxabi-nats"', 'source = { url = "https://example.com/pkg.tar.gz" }'].join( + '\n', + ) + expect(readLockedSha(lock, 'roxabi-nats')).toBeNull() + }) +}) + +// ─── resolveTagAtSha ───────────────────────────────────────────────────────── + +describe('resolveTagAtSha', () => { + const sha = 'abc123def456abc123def456abc123def456abc1' + const gitUrl = 'https://github.com/Roxabi/roxabi-nats' + + function makeDeps(lsRemoteOutput: string): Pick { + return { + run: vi.fn().mockResolvedValue(lsRemoteOutput), + } + } + + it('returns monorepo-style tag when SHA matches', async () => { + const lsRemote = [ + `${sha}\trefs/tags/roxabi-nats/v1.2.3^{}`, + `deadbeef0000deadbeef0000deadbeef0000dead\trefs/tags/roxabi-nats/v1.2.3`, + ].join('\n') + const deps = makeDeps(lsRemote) + const tag = await resolveTagAtSha(gitUrl, sha, 'roxabi-nats', deps) + expect(tag).toBe('roxabi-nats/v1.2.3') + }) + + it('prefers monorepo-style tag over bare vX.Y.Z when both match', async () => { + const lsRemote = [ + `${sha}\trefs/tags/v1.2.3^{}`, + `deadbeef0000deadbeef0000deadbeef0000dead\trefs/tags/v1.2.3`, + `${sha}\trefs/tags/roxabi-nats/v1.2.3^{}`, + `deadbeef0000deadbeef0000deadbeef0000dead\trefs/tags/roxabi-nats/v1.2.3`, + ].join('\n') + const deps = makeDeps(lsRemote) + const tag = await resolveTagAtSha(gitUrl, sha, 'roxabi-nats', deps) + expect(tag).toBe('roxabi-nats/v1.2.3') + }) + + it('falls back to bare vX.Y.Z tag when no monorepo tag matches', async () => { + const lsRemote = [`${sha}\trefs/tags/v0.5.0^{}`, `deadbeef0000deadbeef0000deadbeef0000dead\trefs/tags/v0.5.0`].join( + '\n', + ) + const deps = makeDeps(lsRemote) + const tag = await resolveTagAtSha(gitUrl, sha, 'some-pkg', deps) + expect(tag).toBe('v0.5.0') + }) + + it('returns null when SHA has no matching tag', async () => { + const lsRemote = [`0000000000000000000000000000000000000000\trefs/tags/v1.0.0^{}`].join('\n') + const deps = makeDeps(lsRemote) + const tag = await resolveTagAtSha(gitUrl, sha, 'roxabi-nats', deps) + expect(tag).toBeNull() + }) + + it('returns null when ls-remote fails', async () => { + const deps: Pick = { + run: vi.fn().mockRejectedValue(new Error('network error')), + } + const tag = await resolveTagAtSha(gitUrl, sha, 'roxabi-nats', deps) + expect(tag).toBeNull() + }) + + it('returns null when ls-remote output is empty', async () => { + const deps = makeDeps('') + const tag = await resolveTagAtSha(gitUrl, sha, 'roxabi-nats', deps) + expect(tag).toBeNull() + }) + + it('ignores non-release tags (no vX.Y.Z pattern)', async () => { + const lsRemote = [`${sha}\trefs/tags/latest^{}`, `${sha}\trefs/tags/staging^{}`].join('\n') + const deps = makeDeps(lsRemote) + const tag = await resolveTagAtSha(gitUrl, sha, 'roxabi-nats', deps) + expect(tag).toBeNull() + }) +}) + +// ─── rewritePyproject ───────────────────────────────────────────────────────── + +describe('rewritePyproject', () => { + it('rewrites branch= to tag= in inline table form', () => { + const input = [ + '[tool.uv.sources]', + 'roxabi-nats = { git = "https://github.com/Roxabi/roxabi-nats", branch = "staging" }', + ].join('\n') + const output = rewritePyproject(input, 'roxabi-nats', 'roxabi-nats/v1.2.3') + expect(output).toContain('tag = "roxabi-nats/v1.2.3"') + expect(output).not.toContain('branch =') + }) + + it('rewrites only the target package, not others', () => { + const input = [ + '[tool.uv.sources]', + 'libA = { git = "https://github.com/org/libA", branch = "staging" }', + 'libB = { git = "https://github.com/org/libB", branch = "staging" }', + ].join('\n') + const output = rewritePyproject(input, 'libA', 'v2.0.0') + expect(output).toContain('libA = { git = "https://github.com/org/libA", tag = "v2.0.0" }') + expect(output).toContain('libB = { git = "https://github.com/org/libB", branch = "staging" }') + }) + + it('rewrites branch= to tag= in sub-table form', () => { + const input = [ + '[tool.uv.sources.roxabi-nats]', + 'git = "https://github.com/Roxabi/roxabi-nats"', + 'branch = "staging"', + ].join('\n') + const output = rewritePyproject(input, 'roxabi-nats', 'roxabi-nats/v1.2.3') + expect(output).toContain('tag = "roxabi-nats/v1.2.3"') + expect(output).not.toContain('branch =') + }) + + it('preserves lines outside sources section unchanged', () => { + const input = [ + '[project]', + 'name = "myapp"', + 'version = "1.0.0"', + '', + '[tool.uv.sources]', + 'roxabi-nats = { git = "https://github.com/Roxabi/roxabi-nats", branch = "staging" }', + '', + '[tool.ruff]', + 'line-length = 120', + ].join('\n') + const output = rewritePyproject(input, 'roxabi-nats', 'v1.2.3') + expect(output).toContain('[project]') + expect(output).toContain('name = "myapp"') + expect(output).toContain('[tool.ruff]') + expect(output).toContain('line-length = 120') + }) + + it('does not mutate the input string', () => { + const input = '[tool.uv.sources]\nlibA = { git = "url", branch = "staging" }\n' + const original = input + rewritePyproject(input, 'libA', 'v1.0.0') + expect(input).toBe(original) + }) +}) + +// ─── buildPinSwapPlan ───────────────────────────────────────────────────────── + +describe('buildPinSwapPlan', () => { + const sha = 'abc123def456abc123def456abc123def456abc1' + + function makeDeps(overrides: Partial = {}): Deps { + const pyproject = [ + '[tool.uv.sources]', + `roxabi-nats = { git = "https://github.com/Roxabi/roxabi-nats", branch = "staging" }`, + ].join('\n') + + const uvLock = [ + '[[package]]', + 'name = "roxabi-nats"', + `source = { git = "https://github.com/Roxabi/roxabi-nats", rev = "${sha}" }`, + ].join('\n') + + const lsRemote = [ + `${sha}\trefs/tags/roxabi-nats/v1.2.3^{}`, + `deadbeef0000deadbeef0000deadbeef0000dead\trefs/tags/roxabi-nats/v1.2.3`, + ].join('\n') + + return { + readFile: vi.fn((path: string) => { + if (path.endsWith('pyproject.toml')) return pyproject + if (path.endsWith('uv.lock')) return uvLock + throw new Error(`unexpected readFile: ${path}`) + }), + writeFile: vi.fn(), + run: vi.fn().mockResolvedValue(lsRemote), + ...overrides, + } + } + + it('returns empty candidates when no branch= git deps', async () => { + const deps = makeDeps({ + readFile: vi.fn(() => '[tool.uv.sources]\nlibA = { url = "https://example.com/a.tar.gz" }\n'), + }) + const plan = await buildPinSwapPlan('/repo', deps) + expect(plan.candidates).toHaveLength(0) + }) + + it('builds a plan with resolved candidate', async () => { + const deps = makeDeps() + const plan = await buildPinSwapPlan('/repo', deps) + expect(plan.candidates).toHaveLength(1) + expect(plan.candidates[0]).toMatchObject({ + name: 'roxabi-nats', + branch: 'staging', + sha, + tag: 'roxabi-nats/v1.2.3', + }) + }) + + it('throws when SHA not found in uv.lock', async () => { + const deps = makeDeps({ + readFile: vi.fn((path: string) => { + if (path.endsWith('pyproject.toml')) { + return '[tool.uv.sources]\nroxabi-nats = { git = "https://github.com/Roxabi/roxabi-nats", branch = "staging" }\n' + } + return '[[package]]\nname = "other"\nsource = { url = "x" }\n' + }), + }) + await expect(buildPinSwapPlan('/repo', deps)).rejects.toThrow(/no rev found in uv\.lock/) + }) + + it('throws with actionable message when no tag at SHA', async () => { + const deps = makeDeps({ + run: vi.fn().mockResolvedValue(`0000000000000000000000000000000000000000\trefs/tags/v1.0.0^{}`), + }) + await expect(buildPinSwapPlan('/repo', deps)).rejects.toThrow(/Cut a release tag.*upstream first/) + }) +}) + +// ─── applyPinSwap ───────────────────────────────────────────────────────────── + +describe('applyPinSwap', () => { + const sha = 'abc123def456abc123def456abc123def456abc1' + const pyproject = [ + '[tool.uv.sources]', + 'roxabi-nats = { git = "https://github.com/Roxabi/roxabi-nats", branch = "staging" }', + ].join('\n') + + const plan: PinSwapPlan = { + candidates: [ + { + name: 'roxabi-nats', + gitUrl: 'https://github.com/Roxabi/roxabi-nats', + branch: 'staging', + sha, + tag: 'roxabi-nats/v1.2.3', + }, + ], + } + + it('returns written=false when plan has no candidates', async () => { + const deps: Deps = { + readFile: vi.fn(), + writeFile: vi.fn(), + run: vi.fn().mockResolvedValue(''), + } + const result = await applyPinSwap('/repo', { candidates: [] }, deps) + expect(result).toEqual({ written: false, staged: false }) + expect(deps.writeFile).not.toHaveBeenCalled() + }) + + it('writes rewritten pyproject.toml', async () => { + const writes: [string, string][] = [] + const deps: Deps = { + readFile: vi.fn(() => pyproject), + writeFile: vi.fn((path, content) => writes.push([path, content])), + run: vi.fn().mockResolvedValue(''), + } + await applyPinSwap('/repo', plan, deps) + expect(writes).toHaveLength(1) + const [path, content] = writes[0] + expect(path).toBe('/repo/pyproject.toml') + expect(content).toContain('tag = "roxabi-nats/v1.2.3"') + expect(content).not.toContain('branch =') + }) + + it('runs uv lock to regenerate lock file', async () => { + const runs: string[][] = [] + const deps: Deps = { + readFile: vi.fn(() => pyproject), + writeFile: vi.fn(), + run: vi.fn(async (cmd: string[]) => { + runs.push(cmd) + return '' + }), + } + await applyPinSwap('/repo', plan, deps) + expect(runs).toContainEqual(['uv', 'lock']) + }) + + it('stages pyproject.toml and uv.lock', async () => { + const runs: string[][] = [] + const deps: Deps = { + readFile: vi.fn(() => pyproject), + writeFile: vi.fn(), + run: vi.fn(async (cmd: string[]) => { + runs.push(cmd) + return '' + }), + } + await applyPinSwap('/repo', plan, deps) + expect(runs).toContainEqual(['git', 'add', 'pyproject.toml', 'uv.lock']) + }) + + it('returns written=true and staged=true on success', async () => { + const deps: Deps = { + readFile: vi.fn(() => pyproject), + writeFile: vi.fn(), + run: vi.fn().mockResolvedValue(''), + } + const result = await applyPinSwap('/repo', plan, deps) + expect(result).toEqual({ written: true, staged: true }) + }) +}) + +// ─── formatPlan ─────────────────────────────────────────────────────────────── + +describe('formatPlan', () => { + it('reports skip message for empty plan', () => { + const out = formatPlan({ candidates: [] }) + expect(out).toContain('no branch= git deps found') + }) + + it('shows candidate diff for non-empty plan', () => { + const plan: PinSwapPlan = { + candidates: [ + { + name: 'roxabi-nats', + gitUrl: 'https://github.com/Roxabi/roxabi-nats', + branch: 'staging', + sha: 'abc123def456abc123def456abc123def456abc1', + tag: 'roxabi-nats/v1.2.3', + }, + ], + } + const out = formatPlan(plan) + expect(out).toContain('roxabi-nats') + expect(out).toContain('branch=staging') + expect(out).toContain('tag=roxabi-nats/v1.2.3') + expect(out).toContain('abc123def456') + }) +}) diff --git a/plugins/dev-core/skills/promote/lib/pin-swap.ts b/plugins/dev-core/skills/promote/lib/pin-swap.ts new file mode 100644 index 00000000..d32b254d --- /dev/null +++ b/plugins/dev-core/skills/promote/lib/pin-swap.ts @@ -0,0 +1,386 @@ +/** + * pin-swap.ts — uv git-dep pin-swap phase for /promote + * + * Detects [tool.uv.sources] entries with branch= and rewrites them to tag= + * using the SHA pinned in uv.lock resolved to a matching release tag. + * + * Designed for pure-logic testability: all I/O is injected via Deps interface. + */ + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export interface UvGitSource { + git: string + branch: string +} + +export interface GitDep { + name: string + source: UvGitSource +} + +export interface PinCandidate { + name: string + gitUrl: string + branch: string + sha: string + tag: string +} + +export interface PinSwapPlan { + candidates: PinCandidate[] +} + +export interface ApplyResult { + written: boolean + staged: boolean +} + +// ─── Dependencies (injected for testability) ────────────────────────────────── + +export interface Deps { + /** Read a file from disk, return its text content. */ + readFile: (path: string) => string + /** Write text content to a file on disk. */ + writeFile: (path: string, content: string) => void + /** Run a shell command, return trimmed stdout. Throws on non-zero exit. */ + run: (cmd: string[], cwd?: string) => Promise +} + +// ─── Parsing ────────────────────────────────────────────────────────────────── + +/** + * Parse [tool.uv.sources] from pyproject.toml text. + * Returns only entries that have both `git =` and `branch =` fields. + * + * Handles TOML inline tables: { git = "...", branch = "..." } + * and multi-key blocks under [tool.uv.sources.]. + */ +export function parseUvGitDeps(pyprojectText: string): GitDep[] { + const deps: GitDep[] = [] + const lines = pyprojectText.split('\n') + + // State machine: inside [tool.uv.sources] block? + let inSources = false + let currentName: string | null = null + let currentGit: string | null = null + let currentBranch: string | null = null + + function flushBlock() { + if (currentName && currentGit && currentBranch) { + deps.push({ name: currentName, source: { git: currentGit, branch: currentBranch } }) + } + currentName = null + currentGit = null + currentBranch = null + } + + for (const line of lines) { + const trimmed = line.trim() + + // Section header detection + if (trimmed.startsWith('[')) { + flushBlock() + + // Sub-table header: [tool.uv.sources.name] — sets inSources + currentName + const subTableMatch = trimmed.match(/^\[tool\.uv\.sources\.([^\]]+)\]$/) + if (subTableMatch) { + inSources = true + currentName = subTableMatch[1] + continue + } + + if (trimmed === '[tool.uv.sources]') { + inSources = true + continue + } + + // Any other section header ends sources block + inSources = false + continue + } + + if (!inSources) continue + + // Inline table: name = { git = "...", branch = "..." } + const inlineMatch = trimmed.match(/^(\S+)\s*=\s*\{([^}]*)\}/) + if (inlineMatch && !currentName) { + const name = inlineMatch[1] + const body = inlineMatch[2] + const gitMatch = body.match(/git\s*=\s*["']([^"']+)["']/) + const branchMatch = body.match(/branch\s*=\s*["']([^"']+)["']/) + if (gitMatch && branchMatch) { + deps.push({ name, source: { git: gitMatch[1], branch: branchMatch[1] } }) + } + continue + } + + // Key = value inside a sub-table + if (currentName) { + const gitVal = trimmed.match(/^git\s*=\s*["']([^"']+)["']/) + if (gitVal) { + currentGit = gitVal[1] + continue + } + const branchVal = trimmed.match(/^branch\s*=\s*["']([^"']+)["']/) + if (branchVal) { + currentBranch = branchVal[1] + } + } + } + + flushBlock() + return deps +} + +// ─── Lock file parsing ──────────────────────────────────────────────────────── + +/** + * Read the pinned SHA for a package from uv.lock. + * + * uv.lock format (TOML-like): + * [[package]] + * name = "pkg-name" + * ... + * source = { git = "https://...", rev = "" } + */ +export function readLockedSha(uvLockText: string, pkgName: string): string | null { + const blocks = uvLockText.split(/(?=\[\[package\]\])/g) + for (const block of blocks) { + // Check name matches (normalize hyphens/underscores for comparison) + const nameMatch = block.match(/^name\s*=\s*["']([^"']+)["']/m) + if (!nameMatch) continue + const blockName = nameMatch[1].toLowerCase().replace(/-/g, '_') + const target = pkgName.toLowerCase().replace(/-/g, '_') + if (blockName !== target) continue + + // Extract rev from source line + const revMatch = block.match(/rev\s*=\s*["']([a-f0-9]{40})["']/) + if (revMatch) return revMatch[1] + } + return null +} + +// ─── Tag resolution ─────────────────────────────────────────────────────────── + +/** + * Resolve a SHA to a release tag at that exact commit on the remote. + * + * Fetches tags via `git ls-remote --tags ` and matches + * tags pointing at the given SHA. Supports monorepo-style tags + * like `/vX.Y.Z` as well as flat `vX.Y.Z`. + * + * Returns the best match (prefer `/vX.Y.Z`, fall back to `vX.Y.Z`). + */ +export async function resolveTagAtSha( + gitUrl: string, + sha: string, + pkgName: string, + deps: Pick, +): Promise { + let lsRemoteOut: string + try { + lsRemoteOut = await deps.run(['git', 'ls-remote', '--tags', gitUrl]) + } catch { + return null + } + + if (!lsRemoteOut) return null + + // Parse ls-remote output: "\trefs/tags/" + // Annotated tags have a "^{}" dereference line with the commit SHA + const tagLines = lsRemoteOut.split('\n').filter(Boolean) + + // Build map: tagName → set of SHAs (commit SHA from ^{} line, or direct) + const tagToShas = new Map>() + for (const line of tagLines) { + const parts = line.split('\t') + if (parts.length < 2) continue + const lineSha = parts[0].trim() + const ref = parts[1].trim() + // refs/tags/^{} = dereferenced (points to commit), refs/tags/ = tag object + const tagMatch = ref.match(/^refs\/tags\/(.+?)(\^\{\})?$/) + if (!tagMatch) continue + const tagName = tagMatch[1] + if (!tagToShas.has(tagName)) tagToShas.set(tagName, new Set()) + tagToShas.get(tagName)?.add(lineSha) + } + + // Filter to release-style tags matching the SHA + const matchingTags: string[] = [] + const normalizedPkg = pkgName.replace(/_/g, '-').toLowerCase() + + for (const [tagName, shas] of tagToShas) { + if (!shas.has(sha)) continue + // Accept: vX.Y.Z, /vX.Y.Z, /vX.Y.Z-suffix + if (!/v\d+\.\d+\.\d+/.test(tagName)) continue + matchingTags.push(tagName) + } + + if (matchingTags.length === 0) return null + + // Prefer /vX.Y.Z (monorepo-style) over bare vX.Y.Z + const monorepoMatch = matchingTags.find((t) => t.toLowerCase().startsWith(`${normalizedPkg}/`)) + if (monorepoMatch) return monorepoMatch + + // Fall back to bare version tag + return matchingTags.sort().reverse()[0] ?? null +} + +// ─── pyproject rewriting ────────────────────────────────────────────────────── + +/** + * Rewrite [tool.uv.sources] in pyproject.toml text: + * replace `branch = ""` with `tag = ""` for the given package. + * + * Handles both inline table and sub-table forms. + * Returns the rewritten text (does NOT mutate the input). + */ +export function rewritePyproject(pyprojectText: string, pkgName: string, tag: string): string { + const lines = pyprojectText.split('\n') + const result: string[] = [] + let inSources = false + let inPkgBlock = false + let foundAndRewroteInline = false + + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + const trimmed = line.trim() + + if (trimmed.startsWith('[')) { + inPkgBlock = false + if (trimmed === '[tool.uv.sources]') { + inSources = true + } else if (trimmed === `[tool.uv.sources.${pkgName}]`) { + // Direct sub-table header — sets both inSources and inPkgBlock + inSources = true + inPkgBlock = true + } else if (inSources && trimmed.startsWith('[tool.uv.sources.')) { + // Another sub-table inside sources, but not our target + inSources = true + } else { + inSources = false + } + result.push(line) + continue + } + + // Inline table form: pkgName = { git = "...", branch = "staging" } + if (inSources && !foundAndRewroteInline) { + const inlineRegex = new RegExp(`^(\\s*${pkgName}\\s*=\\s*\\{[^}]*)branch\\s*=\\s*["'][^"']*["']([^}]*\\})`) + if (inlineRegex.test(line)) { + const rewritten = line.replace(/branch\s*=\s*["'][^"']*["']/, `tag = "${tag}"`) + result.push(rewritten) + foundAndRewroteInline = true + continue + } + } + + // Sub-table form: inside [tool.uv.sources.], replace branch = ... line + if (inPkgBlock && /^\s*branch\s*=/.test(line)) { + const indent = line.match(/^(\s*)/)?.[1] ?? '' + result.push(`${indent}tag = "${tag}"`) + continue + } + + result.push(line) + } + + return result.join('\n') +} + +// ─── Plan building ──────────────────────────────────────────────────────────── + +/** + * Build the pin-swap plan: detect branch= git deps, resolve SHAs and tags. + * + * Returns { candidates } where each candidate has: name, gitUrl, branch, sha, tag. + * Throws with an actionable message if a SHA cannot be resolved to a tag. + * + * @param cwd Working directory containing pyproject.toml and uv.lock + */ +export async function buildPinSwapPlan(cwd: string, deps: Deps): Promise { + const pyprojectText = deps.readFile(`${cwd}/pyproject.toml`) + const uvLockText = deps.readFile(`${cwd}/uv.lock`) + + const gitDeps = parseUvGitDeps(pyprojectText) + if (gitDeps.length === 0) return { candidates: [] } + + const candidates: PinCandidate[] = [] + + for (const dep of gitDeps) { + const sha = readLockedSha(uvLockText, dep.name) + if (!sha) { + throw new Error( + `pin-swap: ${dep.name} has branch=${dep.source.branch} in pyproject.toml but no rev found in uv.lock.\n` + + `Run 'uv lock' to sync the lock file first.`, + ) + } + + const tag = await resolveTagAtSha(dep.source.git, sha, dep.name, deps) + if (!tag) { + throw new Error( + `pin-swap: No release tag found at ${dep.name}@${sha.slice(0, 8)} on ${dep.source.git}.\n` + + `Cut a release tag (e.g. ${dep.name}/vX.Y.Z) at ${sha.slice(0, 8)} upstream first.`, + ) + } + + candidates.push({ + name: dep.name, + gitUrl: dep.source.git, + branch: dep.source.branch, + sha, + tag, + }) + } + + return { candidates } +} + +// ─── Apply ──────────────────────────────────────────────────────────────────── + +/** + * Apply the pin-swap plan: rewrite pyproject.toml, regenerate uv.lock, stage both. + * + * @param cwd Working directory + * @param plan Plan from buildPinSwapPlan + */ +export async function applyPinSwap(cwd: string, plan: PinSwapPlan, deps: Deps): Promise { + if (plan.candidates.length === 0) return { written: false, staged: false } + + let pyprojectText = deps.readFile(`${cwd}/pyproject.toml`) + + for (const candidate of plan.candidates) { + pyprojectText = rewritePyproject(pyprojectText, candidate.name, candidate.tag) + } + + deps.writeFile(`${cwd}/pyproject.toml`, pyprojectText) + + // Regenerate lock file + await deps.run(['uv', 'lock'], cwd) + + // Stage both files + await deps.run(['git', 'add', 'pyproject.toml', 'uv.lock'], cwd) + + return { written: true, staged: true } +} + +// ─── Dry-run formatting ─────────────────────────────────────────────────────── + +/** + * Format a pin-swap plan as a human-readable diff for display. + */ +export function formatPlan(plan: PinSwapPlan): string { + if (plan.candidates.length === 0) { + return 'pin-swap: no branch= git deps found — skipping.' + } + + const lines = ['Pin-swap plan:', ''] + for (const c of plan.candidates) { + lines.push(` ${c.name}`) + lines.push(` branch=${c.branch} → tag=${c.tag}`) + lines.push(` SHA: ${c.sha.slice(0, 12)}`) + lines.push('') + } + return lines.join('\n') +}