From a673ab15749cdbc60b1e05000d151ee3605aaae7 Mon Sep 17 00:00:00 2001 From: Philippe L'ATTENTION Date: Sat, 29 Aug 2026 12:38:49 +0200 Subject: [PATCH 1/3] feat: programmatic entry point for generate, check and build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `import { generate, check, build } from "balade"` runs the three commands from a script or a CI job without a terminal. `src/library.ts` is a second root beside `cli.ts`: the same pipelines (`runGeneration`, `checkOne`, `runBuild`) over one `liveLayer` — Node services, the process executor, the Pi author adapter and the context resolver — provided per call so a finished call leaves nothing keeping the process alive. The pre-flight answers the command's two questions with typed errors instead of prompts: `resolveAgentModel` (agent/model.ts) matches an explicit `{ providerId, modelId }` or the saved preference and fails `AgentModelUnresolved` listing what is available or `NoProviderAuthenticated`, never logging in or rewriting the preference; an existing same-head walkthrough fails `ExistingWalkthroughUndecided` naming the files unless `force: true`. `headInstructions` defaults to `omit-changed`. `onProgress` receives the pipeline's events unfiltered — the terminal renderer is one consumer of them. Rejections are the tagged errors themselves with the CLI's sentence attached as `message`. The build now emits declarations and package.json gains an `exports` map; one explicit return type in pi/inspection.ts keeps Pi's typebox parameter types out of the `.d.ts`. The npm smoke test type-checks and runs a consumer against the packed tarball; the architecture test learns the second root and asserts nothing imports either entry. The Pi faux harness moves to test/support/pi.ts so the library test shares it. Closes #153 Claude-Session: https://claude.ai/code/session_01QiAQotDRMwQigFYV9NXv5i --- .changeset/library-entry-point.md | 5 + DECISIONS.md | 69 ++++++- README.md | 33 ++++ docs/threat-model.md | 8 +- package.json | 7 + scripts/npm-smoke.sh | 58 ++++++ src/agent/model.ts | 47 +++++ src/commands/generate/index.ts | 3 +- src/commands/generate/output.ts | 3 + src/library.ts | 299 ++++++++++++++++++++++++++++++ src/pi/inspection.ts | 13 +- test/architecture.test.ts | 16 +- test/generate.test.ts | 41 +--- test/library.test.ts | 266 ++++++++++++++++++++++++++ test/support/pi.ts | 55 ++++++ tsconfig.build.json | 1 + 16 files changed, 869 insertions(+), 55 deletions(-) create mode 100644 .changeset/library-entry-point.md create mode 100644 src/library.ts create mode 100644 test/library.test.ts create mode 100644 test/support/pi.ts diff --git a/.changeset/library-entry-point.md b/.changeset/library-entry-point.md new file mode 100644 index 0000000..87fee67 --- /dev/null +++ b/.changeset/library-entry-point.md @@ -0,0 +1,5 @@ +--- +"balade": minor +--- + +Add a library entry point: `import { generate, check, build } from "balade"` runs the three commands from a script or a CI job without a terminal. `generate` takes the command's options plus an explicit `model` and `onProgress`; it never prompts — an unresolved model, a missing credential or an existing same-head walkthrough without `force: true` rejects with a tagged error whose `message` is the sentence the command prints. `check` returns the report, `build` the outcome. The published package now carries type declarations and an `exports` map. diff --git a/DECISIONS.md b/DECISIONS.md index 734f9ad..38a4f07 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -12,12 +12,13 @@ one folder per CLI verb under `commands/`, the concept folders — `walkthrough/ `authoring/` (the versioned authoring package: typed data plus its renderings), `pi/` (the Pi adapter and agent sessions), `agent/` (shared provider/model configuration), `server/` (live session runtime) — and the root files `cli.ts`, -`shell.ts`, `state.ts`, `terminal.ts`, `failure.ts`, `presence.ts`, `submission.ts`. +`library.ts`, `shell.ts`, `state.ts`, `terminal.ts`, `failure.ts`, `presence.ts`, +`submission.ts`. All imports flow one direction; peers never import each other: ``` -cli.ts entry + layer wiring +cli.ts library.ts entries + layer wiring — the executable and the package export commands/ server/ orchestrators — the ONLY places product concepts compose agent/ → pi, presence, terminal pi/ → authoring, git (type imports), contract, shell @@ -33,9 +34,11 @@ shell.ts state.ts terminal.ts failure.ts presence.ts submission.ts 1. `Command.make` appears only in `commands//index.ts` (plus the root `balade` command in `cli.ts`). `ls src/commands` **is** the CLI surface. 2. A file lives in `commands//` only if that verb is its sole importer. - Nothing outside `commands/` may import from `commands/` (except `cli.ts`). - The review lifecycle shared by `open` and a successful generation therefore - lives in `server/review.ts`, not under either verb. + Nothing outside `commands/` may import from `commands/` (except the two + entries, `cli.ts` and `library.ts`). The review lifecycle shared by `open` + and a successful generation therefore lives in `server/review.ts`, not + under either verb. Nothing imports an entry: importing `cli.ts` would run + it, importing `library.ts` would wire a second service stack. 3. `walkthrough/`, `git/`, `preset/` are autonomous: they import only `contract/` and root ports (`walkthrough/` may additionally import `preset/` — the tag catalog is an extension of the format). Concepts compose @@ -58,8 +61,55 @@ translate them, and `contract/` must import nothing internal, in that order. Enforced two ways: oxlint's `import/no-cycle` (import plugin, `.oxlintrc.json`) rejects file cycles, and `test/architecture.test.ts` walks the real `src/` -import graph and asserts the rules above. What would move this: a second -renderer or a published API, which would force `contract/` to version. +import graph and asserts the rules above. The published API (`library.ts`, +below) did not version `contract/`: the package's own 0.x version is the API +version, and `src/contract/types.ts` reaches consumers only as re-exported +types. What would still move this: a second renderer. + +## The library entry composes the command pipelines without a terminal + +Decided on [#153](https://github.com/basaltbytes/balade/issues/153). +`src/library.ts` is the package's `exports["."]`: `generate`, `check` and +`build` as promises, each with an Effect-returning variant +(`generateWalkthrough`, `checkWalkthrough`, `buildWalkthrough`) and one +`liveLayer` — Node services, the process executor, the Pi author adapter and +the live context resolver; no terminal, browser or agent presence. The layer +is provided per call rather than held in a `ManagedRuntime`, so a finished +call leaves no handle open and a script exits on its own; the CLI never had +to care because `NodeRuntime.runMain` exits the process. + +The paid pipeline is shared: `runGeneration`, `checkOne` and `runBuild` are +the same functions the commands run, and `GenerationProgress` reaches +`onProgress` unfiltered — the terminal renderer is one consumer of those +events. What the library does not share is the command's *interactive* +pre-flight, and the CLI is therefore not a literal wrapper over `generate()`: +the replace prompt sits between inspecting existing walkthroughs and the paid +turn, and the model picker between the plan and the run. Both pre-flights are +composed from the same functions (`parsePrTarget`, `resolvePullHead`, +`inspectExistingWalkthroughs`, `planSupersession`); the library answers the +two questions with typed errors instead — `ExistingWalkthroughUndecided` +naming the files (`force: true` replaces, keeping the superseded copy), and +model resolution through `resolveAgentModel` in `agent/model.ts`, which +matches an explicit `{ providerId, modelId }` or the saved preference and +fails `AgentModelUnresolved` (listing what is available) or +`NoProviderAuthenticated`. It never logs in and never rewrites the preference: +a CI job naming a model must not become the user's default. Local checks fail +before the pull request head is fetched. + +A rejected promise carries the tagged error itself — `_tag`, fields, +`instanceof` — and the library attaches the sentence the CLI would print as +its `message` at the boundary (`withMessage`), because the error classes are +shared with the CLI, whose messages live at *its* boundary, and `message` is +what every promise consumer reads. The result is the CLI's `GenerationResult` +plus the pull-request `notices` the command prints as warnings. + +The build emits declarations (`tsconfig.build.json`, `declaration: true`), +which forced one explicit return type in `src/pi/inspection.ts`: Pi's tool +definitions carry typebox parameter types that a `.d.ts` cannot name +portably. The package smoke test type-checks and runs a consumer against the +packed tarball. What would move this: an Effect caller needing services +beyond `liveLayer`, or a `--progress json` flag, which would consume the same +events from the CLI side. ## The payload contract is Effect Schema @@ -1092,7 +1142,10 @@ preview keeps the source package version instead of using `--previewVersion`: the executable reports a build-time version synced by the release flow, while the preview is selected by its pkg.pr.new URL and does not enter a dependency range or project lockfile. What would move this: publishing a library API whose -preview must participate in dependency resolution. +preview must participate in dependency resolution. [#153](https://github.com/basaltbytes/balade/issues/153) +published that API (`exports["."]`), so the trigger now exists; the workflow +stays as it is until a preview actually needs to enter a consumer's lockfile, +which a `pnpm dlx`/`npx` trial of the preview does not. ## Mermaid draws the logic; the sink does not trust mermaid diff --git a/README.md b/README.md index fdd2d9f..99448e9 100644 --- a/README.md +++ b/README.md @@ -294,6 +294,39 @@ balade; if the installed skill is stale, `check` reports the version mismatch. Use `--out ` for another skill layout (other coding agent harnesses). The npm package also includes the rendered skill under `dist/skill/`. +## Library + +The package exports the three commands as functions, so a script or a CI job +calls them instead of spawning the executable and parsing its output: + +```ts +import { build, check, generate } from "balade"; + +const result = await generate({ + repository: "/path/to/clone", // defaults to the working directory + pullRequest: 96, // a number, "#96", or the PR URL + model: { providerId: "openai-codex", modelId: "gpt-5.4" }, + onProgress: (event) => console.log(event._tag), +}); +// result.file, result.report, result.usage, result.repairs, result.timing, +// result.superseded, result.siblings, result.notices + +const report = await check(result.file); // the report `check --json` prints +const outcome = await build(result.file, { out: "review.html" }); +``` + +`generate` takes the same options as the command: `preset`, `lang`, +`guidance`, `budget`, `directory`, `force` and `headInstructions` +(`"omit-changed"` by default; `"trust-changed"` is the flag's opt-in). `model` +is optional: without it, the preference saved by `balade agent setup` applies. + +Nothing on this path prompts. A model that isn't authenticated, an existing +walkthrough for the same head without `force: true`, or a pull request that +can't be resolved rejects the promise with a tagged error — `error._tag` names +the case, its fields carry the details, and `error.message` is the sentence +the command would have printed. `onProgress` receives the events the command +renders, in order. + ## CI This workflow validates walkthroughs changed by a pull request: diff --git a/docs/threat-model.md b/docs/threat-model.md index f644dd9..167da51 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -69,7 +69,9 @@ PR head. An instruction file changed by the PR is omitted and reported unless the reviewer passes `--trust-head-instructions` after inspecting it. Files that contain a project-context closing tag are rejected regardless of that flag (`src/pi/authoring.ts`, `src/pi/project-context.ts`; see -[#61](https://github.com/basaltbytes/balade/issues/61)). +[#61](https://github.com/basaltbytes/balade/issues/61)). The library entry +(`src/library.ts`) keeps the same default: `headInstructions` is +`"omit-changed"` unless the caller writes `"trust-changed"`. Linked issues are fetched with the reviewer's own GitHub token. Same-repository issues stay under author-stated intent; cross-repository issues remain available @@ -385,8 +387,8 @@ they describe. provenance is classified. A malformed location drops the optional GitHub enrichment with a notice instead of becoming a guessed third-party label. - Generation admits changed PR-head `AGENTS.md` and `CLAUDE.md` files only when - explicitly trusted with `--trust-head-instructions`; clarification always - omits them. Project-context closing tags are rejected before interpolation in + explicitly trusted with `--trust-head-instructions` (the library's + `headInstructions: "trust-changed"`); clarification always omits them. Project-context closing tags are rejected before interpolation in both workflows. - The snapshot is `git archive ` with lexical, symlink and realpath containment (`src/pi/snapshot.ts:135-172`, tested in diff --git a/package.json b/package.json index c3d8f40..2355ef9 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,13 @@ "dist" ], "type": "module", + "exports": { + ".": { + "types": "./dist/library.d.ts", + "default": "./dist/library.js" + }, + "./package.json": "./package.json" + }, "publishConfig": { "access": "public", "provenance": true diff --git a/scripts/npm-smoke.sh b/scripts/npm-smoke.sh index 82b2ff0..7df1d66 100644 --- a/scripts/npm-smoke.sh +++ b/scripts/npm-smoke.sh @@ -84,6 +84,64 @@ grep -Fqi "Not inside a git repository" "$GENERATE_STDERR" # Zero-arg check outside a git repository reports and exits 0. "$BIN" check | grep -qi "nothing to check" +# The package is also a library: the three calls type-check and run from a +# consumer project, rejections are the tagged errors with a sentence attached, +# and a finished call leaves nothing keeping the process alive. +cat >"$PROJECT/consumer.ts" <<'EOF_CONSUMER' +import { build, check, generate, type GenerateResult } from "balade"; + +export async function walkthrough(pullRequest: number): Promise { + const result = await generate({ + pullRequest, + model: { providerId: "openai-codex", modelId: "gpt-5.4" }, + headInstructions: "omit-changed", + onProgress: (event) => console.log(event._tag), + }); + await check(result.file); + await build(result.file, { out: "review.html" }); + return result; +} +EOF_CONSUMER +cat >"$PROJECT/tsconfig.json" <<'EOF_TSCONFIG' +{ + "compilerOptions": { + "module": "nodenext", + "moduleResolution": "nodenext", + "target": "es2023", + "strict": true, + "exactOptionalPropertyTypes": true, + "noEmit": true, + "skipLibCheck": true, + "types": [] + }, + "files": ["consumer.ts"] +} +EOF_TSCONFIG +"$ROOT/node_modules/.bin/tsc" -p "$PROJECT/tsconfig.json" +cat >"$PROJECT/consumer.mjs" <<'EOF_RUNTIME' +import { build, check, generate } from "balade"; + +const rejection = (promise) => promise.then(() => null, (error) => error); +const invalid = await rejection(generate({ pullRequest: "not-a-pull-request" })); +if (invalid?._tag !== "PullTargetInvalid" || !invalid.message.includes("pull request")) { + throw new Error(`generate did not reject typed: ${String(invalid)}`); +} +const report = await check("missing.md"); +if (report.ok !== false || report.diagnostics.length === 0) { + throw new Error("check did not report the missing file"); +} +const unreadable = await rejection(build("missing.md", { out: "missing.html" })); +if (unreadable?._tag !== "WalkthroughFileReadFailed" || !unreadable.message.includes("could not read")) { + throw new Error(`build did not reject typed: ${String(unreadable)}`); +} +setTimeout(() => { + console.error("the library left a handle open after its calls settled"); + process.exit(1); +}, 15_000).unref(); +console.log("library smoke passed"); +EOF_RUNTIME +node "$PROJECT/consumer.mjs" | grep -q "library smoke passed" + # A real install writes the shared convention; .claude/ only once it exists. SKILL_REPO="$TMP_ROOT/skill-repo" mkdir -p "$SKILL_REPO" diff --git a/src/agent/model.ts b/src/agent/model.ts index 037613f..568c219 100644 --- a/src/agent/model.ts +++ b/src/agent/model.ts @@ -26,6 +26,15 @@ export class AgentModelSelectionCancelled extends Schema.TaggedErrorClass()( + "AgentModelUnresolved", + { requested: Schema.String, available: Schema.Array(AuthorModelSchema) }, +) {} + export class AgentModelReady extends Schema.TaggedClass()("AgentModelReady", { model: AuthorModelSchema, }) {} @@ -46,6 +55,12 @@ export type ModelSelection = | { readonly _tag: "UsePreference" } | { readonly _tag: "Choose"; readonly filter: ModelFilter }; +/** A provider and model named outright — what a script passes; nothing partial, nothing to pick from. */ +export interface ExplicitModel { + readonly providerId: string; + readonly modelId: string; +} + export type AgentModelNotice = | { readonly _tag: "SetupRequired" } | { readonly _tag: "PreferenceReadFailed" } @@ -74,6 +89,13 @@ export type AgentModelConfigurationError = | NoProviderAuthenticated | AgentModelSelectionCancelled; +/** The non-interactive resolution's failures: nothing here can be answered by a prompt. */ +export type AgentModelResolutionError = + | AuthorDiscoveryFailed + | AuthorPreferenceReadFailed + | NoProviderAuthenticated + | AgentModelUnresolved; + export type AgentLogoutError = AuthorCredentialReadFailed | AuthorLogoutFailed; export type AgentModelError = AgentModelConfigurationError | AgentLogoutError; @@ -172,6 +194,31 @@ export const readAgentModelState = Effect.fn("readAgentModelState")(function* ( : new AgentModelSetupRequired(); }); +/** + * The resolution a script gets: an explicit model matches one authenticated + * model or fails naming the available ones; absent, the saved preference + * stands or fails the same way. Nothing here prompts, logs in or rewrites the + * preference — that workflow is `configure`, behind a terminal. + */ +export const resolveAgentModel = Effect.fn("resolveAgentModel")(function* ( + author: WalkthroughAuthorPort, + requested: Option.Option, +) { + const available = yield* author.availableModels; + const wanted = Option.match(requested, { + onNone: () => "the saved model preference", + onSome: (model) => `${model.providerId}/${model.modelId}`, + }); + if (available.length === 0) return yield* new NoProviderAuthenticated({ requested: wanted }); + const selected = Option.isSome(requested) + ? Option.fromNullishOr(matchingModels(available, requested.value)[0]) + : preferredModel(available, yield* author.modelPreference); + if (Option.isNone(selected)) { + return yield* new AgentModelUnresolved({ requested: wanted, available }); + } + return selected.value; +}); + export const makeAgentModelManager = Effect.fn("makeAgentModelManager")(function* ( author: WalkthroughAuthorPort, interaction: AgentModelInteraction, diff --git a/src/commands/generate/index.ts b/src/commands/generate/index.ts index c186030..c23c9f0 100644 --- a/src/commands/generate/index.ts +++ b/src/commands/generate/index.ts @@ -38,6 +38,7 @@ import { type GenerationProgressMode, } from "./progress-terminal.js"; import { + DEFAULT_WALKTHROUGH_DIRECTORY, inspectExistingWalkthroughs, planSupersession, type ExistingWalkthrough, @@ -75,7 +76,7 @@ const lang = Flag.choice("lang", ["en", "fr"]).pipe( const directory = Flag.string("dir").pipe( Flag.withDescription("Repository-relative directory for the generated walkthrough"), - Flag.withDefault(".agents/walkthroughs"), + Flag.withDefault(DEFAULT_WALKTHROUGH_DIRECTORY), ); const guidance = Flag.string("prompt").pipe( diff --git a/src/commands/generate/output.ts b/src/commands/generate/output.ts index eb0b7dd..6177b6d 100644 --- a/src/commands/generate/output.ts +++ b/src/commands/generate/output.ts @@ -8,6 +8,9 @@ import type { Lang } from "../../contract/types.js"; import { gitOut } from "../../shell.js"; import { frontmatterBlock, parseFrontmatter } from "../../walkthrough/frontmatter.js"; +/** Where a generated walkthrough lands unless `--dir` (or the library's `directory`) says otherwise. */ +export const DEFAULT_WALKTHROUGH_DIRECTORY = ".agents/walkthroughs"; + export class OutputOutsideRepository extends Schema.TaggedErrorClass()( "OutputOutsideRepository", { directory: Schema.String, root: Schema.String }, diff --git a/src/library.ts b/src/library.ts new file mode 100644 index 0000000..cf8bf1d --- /dev/null +++ b/src/library.ts @@ -0,0 +1,299 @@ +/** + * The programmatic entry: `generate`, `check` and `build` as promises, with no + * terminal in the loop. A script or a CI job imports this instead of spawning + * the executable and parsing its prose. The paid pipeline is the one the CLI + * runs; what differs is the pre-flight — no model picker, no replace prompt — + * and what a failure looks like: the tagged error itself, rejected, carrying + * the sentence the CLI would have printed. + */ + +import { NodeServices } from "@effect/platform-node"; +import { Effect, Layer, Option, Schema } from "effect"; +import { + resolveAgentModel, + type AgentModelResolutionError, + type ExplicitModel, +} from "./agent/model.js"; +import { agentModelErrorMessage } from "./agent/terminal.js"; +import type { InspectionTier } from "./authoring/package.js"; +import { + buildErrorMessage, + runBuild, + type BuildOptions as BuildPipelineOptions, + type BuildOutcome, +} from "./commands/build/pipeline.js"; +import { + DEFAULT_WALKTHROUGH_DIRECTORY, + inspectExistingWalkthroughs, + planSupersession, +} from "./commands/generate/output.js"; +import { + generateErrorMessage as generationErrorMessage, + runGeneration, + type GenerateError as GenerationError, + type GenerationResult, +} from "./commands/generate/pipeline.js"; +import type { GenerationProgress } from "./commands/generate/progress.js"; +import { langOfMeta } from "./contract/schema.js"; +import type { CheckReport, Lang } from "./contract/types.js"; +import { contextResolverLive } from "./git/git.js"; +import type { PullNotice } from "./git/intent.js"; +import { parsePrTarget, resolvePullHead } from "./git/pr.js"; +import { + WalkthroughAuthor, + type AuthoringPreset, + type HeadInstructionPolicy, +} from "./pi/author.js"; +import { piWalkthroughAuthorLive } from "./pi/client.js"; +import { getPreset, presetNames } from "./preset/registry.js"; +import { CommandExecutor } from "./shell.js"; +import { checkOne, type CheckFileOptions } from "./walkthrough/checker.js"; + +export type { + AgentModelResolutionError, + AgentModelUnresolved, + ExplicitModel, + NoProviderAuthenticated, +} from "./agent/model.js"; +export type { InspectionTier } from "./authoring/package.js"; +export type { + BuildError, + BuildFailed, + BuildNotRun, + BuildOutcome, + Built, +} from "./commands/build/pipeline.js"; +export type { SupersededWalkthrough } from "./commands/generate/output.js"; +export type { + Generated, + GeneratedWithDiagnostics, + GenerationResult, +} from "./commands/generate/pipeline.js"; +export type { + GenerationProgress, + GenerationStatus, + GenerationTiming, + GenerationTimingSegment, +} from "./commands/generate/progress.js"; +export type { CheckDiagnostic, CheckReport, Lang, RangeEcho } from "./contract/types.js"; +export type { PullNotice } from "./git/intent.js"; +export type { AuthorModel, AuthorUsage, HeadInstructionPolicy } from "./pi/author.js"; + +/** + * Host services and the process adapter, then the adapters that need them: + * the executable's stack minus the terminal, the browser and agent presence. + * Built once per call, so a finished call holds no handle open. + */ +export const liveLayer = Layer.mergeAll(piWalkthroughAuthorLive, contextResolverLive).pipe( + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, CommandExecutor.layer)), +); + +/** `pullRequest` names no GitHub pull request. */ +export class PullTargetInvalid extends Schema.TaggedErrorClass()( + "PullTargetInvalid", + { target: Schema.String }, +) {} + +export class PresetUnknown extends Schema.TaggedErrorClass()("PresetUnknown", { + preset: Schema.String, + available: Schema.Array(Schema.String), +}) {} + +/** + * Same-identity walkthroughs the CLI would ask about: stamped at the current + * head, or without a readable stamp. `force: true` replaces them. + */ +export class ExistingWalkthroughUndecided extends Schema.TaggedErrorClass()( + "ExistingWalkthroughUndecided", + { files: Schema.Array(Schema.String) }, +) {} + +export interface GenerateOptions { + /** A path inside the clone; the repository root is resolved from it. Defaults to the working directory. */ + readonly repository?: string; + /** Bare number, `#number`, or GitHub pull request URL. A URL also pins the `owner/name` the clone must match. */ + readonly pullRequest: number | string; + /** The provider and model to author with. Omitted, the preference saved by `balade agent setup` applies. */ + readonly model?: ExplicitModel; + /** Activates a preset's tags for this walkthrough and stamps it. */ + readonly preset?: string; + /** The walkthrough is authored and stamped in this language. */ + readonly lang?: Lang; + /** Reviewer guidance appended to the base prompt for this run. */ + readonly guidance?: string; + /** Inspection budget; `medium` scales with the pull request. */ + readonly budget?: InspectionTier; + /** Repository-relative output directory. Defaults to `.agents/walkthroughs`. */ + readonly directory?: string; + /** Replace an existing same-head walkthrough instead of failing with `ExistingWalkthroughUndecided`. */ + readonly force?: boolean; + /** Whether `AGENTS.md` or `CLAUDE.md` files changed by the pull request apply. Defaults to `omit-changed`. */ + readonly headInstructions?: HeadInstructionPolicy; + /** Receives every progress event the CLI renders, in order. */ + readonly onProgress?: (event: GenerationProgress) => void; +} + +/** The CLI's result, plus the pull-request notices it would have printed as warnings. */ +export type GenerateResult = GenerationResult & { readonly notices: readonly PullNotice[] }; + +export type GenerateError = + | PullTargetInvalid + | PresetUnknown + | ExistingWalkthroughUndecided + | AgentModelResolutionError + | GenerationError; + +type GenerationFacets = { + preset?: AuthoringPreset; + lang?: Lang; + guidance?: string; + budget?: InspectionTier; +}; + +const noProgress = (): void => {}; + +/** + * The pre-flight the `generate` command runs, minus its two questions, then + * the pipeline it runs. Local checks fail first — the target, the preset, the + * model — before the pull request head is fetched. + */ +export const generateWalkthrough = Effect.fn("generateWalkthrough")( + function* (options: GenerateOptions) { + const target = parsePrTarget(String(options.pullRequest)); + if (target === null) { + return yield* new PullTargetInvalid({ target: String(options.pullRequest) }); + } + const preset = options.preset === undefined ? undefined : getPreset(options.preset); + if (options.preset !== undefined && preset === undefined) { + return yield* new PresetUnknown({ preset: options.preset, available: presetNames() }); + } + const author = yield* WalkthroughAuthor; + const model = yield* resolveAgentModel(author, Option.fromNullishOr(options.model)); + + const source = yield* resolvePullHead({ cwd: options.repository ?? process.cwd(), target }); + const directory = options.directory ?? DEFAULT_WALKTHROUGH_DIRECTORY; + const existing = yield* inspectExistingWalkthroughs({ + root: source.root, + directory, + pullNumber: source.pull.number, + }); + const plan = planSupersession(existing, source.pin, langOfMeta(options.lang)); + if (plan.undecided.length > 0 && options.force !== true) { + return yield* new ExistingWalkthroughUndecided({ + files: plan.undecided.map((candidate) => candidate.relativeFile), + }); + } + + const facets: GenerationFacets = {}; + if (preset !== undefined) facets.preset = { name: preset.name, authoring: preset.authoring }; + if (options.lang !== undefined) facets.lang = options.lang; + if (options.guidance !== undefined) facets.guidance = options.guidance; + if (options.budget !== undefined) facets.budget = options.budget; + const result = yield* runGeneration({ + source, + model, + directory, + supersede: [...plan.refreshing, ...plan.undecided], + headInstructionPolicy: options.headInstructions ?? "omit-changed", + progress: options.onProgress ?? noProgress, + ...facets, + }); + return { ...result, notices: source.notices } satisfies GenerateResult; + }, + Effect.mapError((error) => withMessage(error, generateErrorMessage(error))), +); + +export function generate(options: GenerateOptions): Promise { + return Effect.runPromise(generateWalkthrough(options).pipe(Effect.provide(liveLayer))); +} + +export interface CheckOptions { + /** What a relative `file` is resolved against. Defaults to the working directory. */ + readonly cwd?: string; + /** `false` skips gh entirely — CI without auth. */ + readonly useGh?: boolean; +} + +/** The report `balade check --json` prints for one file. Diagnostics are values: this never fails. */ +export const checkWalkthrough = Effect.fn("checkWalkthrough")(function* ( + file: string, + options: CheckOptions = {}, +) { + const fileOptions: CheckFileOptions = { cwd: options.cwd ?? process.cwd(), path: file }; + if (options.useGh !== undefined) fileOptions.useGh = options.useGh; + return yield* checkOne(fileOptions); +}); + +export function check(file: string, options?: CheckOptions): Promise { + return Effect.runPromise(checkWalkthrough(file, options).pipe(Effect.provide(liveLayer))); +} + +export interface BuildOptions { + /** What a relative `file` or `out` is resolved against. Defaults to the working directory. */ + readonly cwd?: string; + /** Output path, absolute or relative to `cwd`. Defaults to `.html` beside the file. */ + readonly out?: string; + /** Chrome language override; the walkthrough's own `meta.lang` applies otherwise. */ + readonly lang?: Lang; + /** `false` skips gh entirely — CI without auth. */ + readonly useGh?: boolean; + /** Where the export bundle is read from; defaults to the one shipped with balade. */ + readonly bundleDir?: string; +} + +/** `balade build` for one file: the outcome the command prints, as a value. */ +export const buildWalkthrough = Effect.fn("buildWalkthrough")( + function* (file: string, options: BuildOptions = {}) { + const buildOptions: BuildPipelineOptions = { + cwd: options.cwd ?? process.cwd(), + paths: [file], + }; + if (options.out !== undefined) buildOptions.out = options.out; + if (options.lang !== undefined) buildOptions.lang = options.lang; + if (options.useGh !== undefined) buildOptions.useGh = options.useGh; + if (options.bundleDir !== undefined) buildOptions.bundleDir = options.bundleDir; + return yield* runBuild(buildOptions); + }, + Effect.mapError((error) => withMessage(error, buildErrorMessage(error))), +); + +export function build(file: string, options?: BuildOptions): Promise { + return Effect.runPromise(buildWalkthrough(file, options).pipe(Effect.provide(liveLayer))); +} + +/** + * The CLI prints one sentence per failure at its boundary; a rejected promise + * is this entry's boundary, and `message` is what every consumer reads there. + * The instance stays the tagged error — `_tag`, fields, `instanceof` — so a + * caller can still match on it. + */ +function withMessage(error: E, message: string): E { + Object.defineProperty(error, "message", { value: message, configurable: true, writable: true }); + return error; +} + +function generateErrorMessage(error: GenerateError): string { + switch (error._tag) { + case "PullTargetInvalid": + return `Name one GitHub pull request — a number, '#number' or its URL — not ${JSON.stringify(error.target)}.`; + case "PresetUnknown": + return `Unknown preset \`${error.preset}\`. Available: ${error.available.join(", ")}.`; + case "ExistingWalkthroughUndecided": + return ( + `${error.files.join(", ")} already ${error.files.length === 1 ? "exists" : "exist"} for this pull request ` + + "at the current head or without a readable stamp. Pass `force: true` to replace, or `directory` to write elsewhere." + ); + case "AuthorDiscoveryFailed": + case "NoProviderAuthenticated": + return agentModelErrorMessage(error); + case "AuthorPreferenceReadFailed": + return "The saved agent model could not be read. Pass `model` explicitly, or run `balade agent setup` to save one again."; + case "AgentModelUnresolved": + return ( + `No authenticated agent model matches ${error.requested}. Available: ` + + `${error.available.map((model) => `${model.providerId}/${model.modelId}`).join(", ")}.` + ); + default: + return generationErrorMessage(error); + } +} diff --git a/src/pi/inspection.ts b/src/pi/inspection.ts index 1714adb..34b89a0 100644 --- a/src/pi/inspection.ts +++ b/src/pi/inspection.ts @@ -1,8 +1,8 @@ /** Shared, pinned, read-only repository tools for every Pi review run. */ -import type { GrepToolDetails } from "@earendil-works/pi-coding-agent"; +import type { GrepToolDetails, ToolDefinition } from "@earendil-works/pi-coding-agent"; import { Effect, FileSystem, Path } from "effect"; -import type { InspectionTier } from "../authoring/package.js"; +import type { InspectionBudget, InspectionTier } from "../authoring/package.js"; import { inspectionBudget } from "../authoring/package.js"; import { CommandExecutor, gitOut } from "../shell.js"; import type { AuthorChangedFile } from "./author.js"; @@ -28,6 +28,13 @@ interface PiInspectionDependencies { readonly ai: typeof import("@earendil-works/pi-ai"); } +/** Named so the declaration emit never has to spell Pi's typebox parameter types. */ +export interface PiInspectionTools { + readonly budget: InspectionBudget; + readonly tools: readonly ToolDefinition[]; + readonly reset: () => void; +} + const MAX_TREE_FILES = 2_000; const MAX_SOURCE_LINES = 400; const MAX_DIFF_LINES = 800; @@ -39,7 +46,7 @@ export async function createInspectionTools( request: PiInspectionRequest, runSessionEffect: RunSessionEffect, snapshot: PinnedRepositorySnapshot, -) { +): Promise { let diffReads = 0; let searches = 0; let sourceReads = 0; diff --git a/test/architecture.test.ts b/test/architecture.test.ts index d5fd598..d24c2fb 100644 --- a/test/architecture.test.ts +++ b/test/architecture.test.ts @@ -37,6 +37,9 @@ function importsOf(file: string): string[] { return found; } +/** The two roots: the executable and the library. They compose freely, and nothing imports them. */ +const ENTRIES = ["cli.ts", "library.ts"]; + /** The layer a module belongs to: a concept folder, `commands/`, or its root file. */ function layerOf(module: string): string { const [head, verb] = module.split("/"); @@ -72,7 +75,7 @@ describe("the src/ dependency law", () => { const edges = files.flatMap((file) => importsOf(file).map((target) => ({ file, target }))); it("finds the modules it polices", () => { - expect(files).toContain("cli.ts"); + for (const entry of ENTRIES) expect(files).toContain(entry); expect(files).toContain("walkthrough/pipeline.ts"); expect(edges.length).toBeGreaterThan(50); }); @@ -86,20 +89,25 @@ describe("the src/ dependency law", () => { it("keeps concepts and root utils on their allowed imports", () => { const violations = edges.filter(({ file, target }) => { const allowed = CONCEPT_EDGES.get(layerOf(file)); - if (allowed === undefined) return false; // cli.ts, commands/, server/ compose freely + if (allowed === undefined) return false; // entries, commands/, server/ compose freely return !allowed.includes(layerOf(target)); }); expect(violations).toEqual([]); }); - it("keeps the command boundary private to cli.ts and its own verb", () => { + it("keeps the command boundary private to the entries and its own verb", () => { const violations = edges.filter(({ file, target }) => { if (!target.startsWith("commands/")) return false; - return file !== "cli.ts" && layerOf(file) !== layerOf(target); + return !ENTRIES.includes(file) && layerOf(file) !== layerOf(target); }); expect(violations).toEqual([]); }); + it("keeps the entries as roots: importing cli.ts would run it, importing library.ts would wire a second stack", () => { + const inbound = edges.filter(({ target }) => ENTRIES.includes(target)); + expect(inbound).toEqual([]); + }); + it("mounts verbs only at the boundary", () => { const misplaced = files.filter((file) => { if (file === "cli.ts") return false; diff --git a/test/generate.test.ts b/test/generate.test.ts index 3879a38..caade0a 100644 --- a/test/generate.test.ts +++ b/test/generate.test.ts @@ -2,7 +2,7 @@ import * as ai from "@earendil-works/pi-ai"; import * as coding from "@earendil-works/pi-coding-agent"; -import { Effect, Fiber, Layer, Option, Redacted, Schema, Terminal } from "effect"; +import { Effect, Fiber, Option, Redacted, Schema, Terminal } from "effect"; import { execFileSync } from "node:child_process"; import { existsSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -15,7 +15,6 @@ import { type AuthoringRequest, type AuthorProgress, } from "../src/pi/author.js"; -import { piWalkthroughAuthorLayer } from "../src/pi/client.js"; import { authoringSystemPrompt } from "../src/pi/authoring.js"; import { inspectionBudget } from "../src/authoring/package.js"; import { renderDraft, runGeneration } from "../src/commands/generate/pipeline.js"; @@ -26,8 +25,7 @@ import { } from "../src/commands/generate/progress-terminal.js"; import { slugifyTitle, type ExistingWalkthrough } from "../src/commands/generate/output.js"; import { makeAgentModelManager, type AgentModelNotice } from "../src/agent/model.js"; -import { shellLayer } from "./support/effect.js"; -import { contextResolverLive } from "../src/git/git.js"; +import { deferCleanup, piHarness, releasePiHarnesses } from "./support/pi.js"; import type { PullSnapshot } from "../src/git/pr.js"; import { createFixtureRepo } from "./support/repo.js"; import { scriptedTerminal } from "./support/terminal.js"; @@ -41,36 +39,7 @@ const CHANGED_FILE = { deletions: 1, }; -async function piHarness(registerFaux = true, settingsManager = coding.SettingsManager.inMemory()) { - const snapshotCacheRoot = mkdtempSync(join(tmpdir(), "balade-pi-snapshots-")); - harnessCleanups.push(() => - rmSync(snapshotCacheRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }), - ); - const credentials = new ai.InMemoryCredentialStore(); - const modelRuntime = await coding.ModelRuntime.create({ - credentials, - modelsPath: null, - allowModelNetwork: false, - }); - const faux = ai.fauxProvider(); - if (registerFaux) { - modelRuntime.registerNativeProvider(faux.provider); - await modelRuntime.refresh({ allowNetwork: false }); - } - const layer = Layer.mergeAll( - piWalkthroughAuthorLayer({ - snapshotCacheRoot, - load: async () => ({ coding, ai, modelRuntime, settingsManager }), - }), - contextResolverLive, - ).pipe(Layer.provideMerge(shellLayer)); - return { credentials, faux, layer, modelRuntime, settingsManager, snapshotCacheRoot }; -} - -const harnessCleanups: Array<() => void> = []; -afterEach(() => { - for (const cleanup of harnessCleanups.splice(0)) cleanup(); -}); +afterEach(releasePiHarnesses); const fixture = Effect.acquireRelease(Effect.sync(createFixtureRepo), (repo) => Effect.sync(() => repo.cleanup()), @@ -412,7 +381,7 @@ describe("the Pi adapter", () => { const repo = yield* fixture; const harness = yield* Effect.promise(() => piHarness()); const outside = mkdtempSync(join(tmpdir(), "balade-outside-")); - harnessCleanups.push(() => + deferCleanup(() => rmSync(outside, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }), ); writeFileSync( @@ -426,7 +395,7 @@ describe("the Pi adapter", () => { const userConfiguration = join(outside, "rg.conf"); writeFileSync(userConfiguration, "--follow\n--glob=!models/*\n", "utf8"); const previous = process.env.RIPGREP_CONFIG_PATH; - harnessCleanups.push(() => { + deferCleanup(() => { if (previous === undefined) delete process.env.RIPGREP_CONFIG_PATH; else process.env.RIPGREP_CONFIG_PATH = previous; }); diff --git a/test/library.test.ts b/test/library.test.ts new file mode 100644 index 0000000..6292f4c --- /dev/null +++ b/test/library.test.ts @@ -0,0 +1,266 @@ +/** + * The programmatic entry through Pi's faux author and real fixture clones: + * no terminal, typed refusals, and the same events the CLI renders. + */ + +import * as ai from "@earendil-works/pi-ai"; +import { NodeServices } from "@effect/platform-node"; +import { execFileSync } from "node:child_process"; +import { Effect, Layer } from "effect"; +import { + existsSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "@effect/vitest"; +import { makeGenerationProgress } from "../src/commands/generate/progress-terminal.js"; +import type { GenerationProgress } from "../src/commands/generate/progress.js"; +import { contextResolverLive } from "../src/git/git.js"; +import { + buildWalkthrough, + checkWalkthrough, + generate, + generateWalkthrough, + type GenerateOptions, +} from "../src/library.js"; +import { plainTheme } from "../src/terminal.js"; +import { unavailableGhLayer } from "./support/command.js"; +import { piHarness, releasePiHarnesses } from "./support/pi.js"; +import { cloneOnMain, createFixtureRepo, type FixtureRepo } from "./support/repo.js"; + +afterEach(releasePiHarnesses); + +/** gh stays deterministic: the fixture origin is a directory, never a GitHub repository. */ +const libraryShell = Layer.mergeAll(NodeServices.layer, unavailableGhLayer); +const resolverLayer = contextResolverLive.pipe(Layer.provideMerge(libraryShell)); + +const fixture = Effect.acquireRelease(Effect.sync(createFixtureRepo), (repo) => + Effect.sync(() => repo.cleanup()), +); + +/** A reviewer's clone: `origin/HEAD` names `main`, as a GitHub clone's does, so the diff base is the merge-base. */ +const cloneOf = (origin: FixtureRepo, pull: number) => + Effect.acquireRelease( + Effect.sync(() => { + const clone = cloneOnMain(origin, pull); + execFileSync("git", ["remote", "set-head", "origin", "main"], { cwd: clone.dir }); + execFileSync("git", ["fetch", "--quiet", "origin", "main"], { cwd: clone.dir }); + return clone; + }), + (clone) => Effect.sync(() => clone.cleanup()), + ); + +const FAUX_MODEL = { providerId: "faux", modelId: "faux-1" }; +const PINNED_LINE = "from odoo import api, fields, models"; +const WALKTHROUGH = ".agents/walkthroughs/pr-42-live-planning-pool.md"; + +const validBody = `{% group label="Overview" %} +{% section id="overview" title="Pool model" %} + +The pool model computes live placement from slots. + +{% code file="models/planning_pool_item.py" from=1 to=8 expect="${PINNED_LINE}" /%} + +{% /section %} +{% /group %} + +{% group label="Full PR diff" %} +{% section id="files" title="Full PR diff" %} + +{% files /%} + +{% /section %} +{% /group %}`; + +const submitted = () => + ai.fauxAssistantMessage( + ai.fauxToolCall("submit_walkthrough", { + title: "Live planning pool", + meta: { lang: "en", module: "acme_planning" }, + body: validBody, + }), + { stopReason: "toolUse" }, + ); + +const stubBundle = Effect.acquireRelease( + Effect.sync(() => { + const dir = mkdtempSync(join(tmpdir(), "balade-library-export-")); + writeFileSync(join(dir, "app.js"), "window.__BALADE_STUB__ = true;\n", "utf8"); + writeFileSync(join(dir, "app.css"), "#root{color:#fff}\n", "utf8"); + return dir; + }), + (dir) => Effect.sync(() => rmSync(dir, { recursive: true, force: true })), +); + +describe("the library entry", () => { + it.effect( + "generates without a terminal: omits changed instructions by default, refuses a same-head replacement, replaces on force", + () => + Effect.gen(function* () { + const origin = yield* fixture; + origin.write("AGENTS.md", "PINNED HEAD INSTRUCTIONS\n"); + const pin = origin.commit("docs: instructions changed by the pull request"); + const clone = yield* cloneOf(origin, 42); + const harness = yield* Effect.promise(() => piHarness(true, undefined, libraryShell)); + const systemPrompts: string[] = []; + harness.faux.setResponses([ + (context) => { + systemPrompts.push(context.systemPrompt ?? ""); + return submitted(); + }, + (context) => { + systemPrompts.push(context.systemPrompt ?? ""); + return submitted(); + }, + ]); + const events: GenerationProgress[] = []; + const options: GenerateOptions = { + repository: clone.dir, + pullRequest: "#42", + model: FAUX_MODEL, + onProgress: (event) => events.push(event), + }; + const run = (overrides: Partial) => + generateWalkthrough({ ...options, ...overrides }).pipe(Effect.provide(harness.layer)); + + /* Default policy: the changed AGENTS.md is skipped and reported; nothing asked. */ + const first = yield* run({}); + expect(first._tag).toBe("Generated"); + /* Paths come back canonical, the way git reports the root. */ + expect(first.file).toBe(join(realpathSync(clone.dir), WALKTHROUGH)); + expect(readFileSync(first.file, "utf8")).toContain(`commit: ${pin}`); + expect(first.repairs).toBe(0); + expect(first.usage.total).toBeGreaterThan(0); + expect(first.timing.totalMilliseconds).toBeGreaterThan(0); + expect(first.notices.map((notice) => notice.code)).toContain("gh-unavailable"); + expect(systemPrompts[0]).not.toContain("PINNED HEAD INSTRUCTIONS"); + expect( + events.flatMap((event) => (event._tag === "AuthorNotice" ? [event.code] : [])), + ).toEqual(["head-instructions-skipped"]); + /* The terminal renderer is one consumer of these events: it renders them as-is. */ + const statuses = events.flatMap((event) => + event._tag === "GenerationStatusChanged" ? [event.status] : [], + ); + expect(statuses[0]).toEqual({ _tag: "PreparingGeneration" }); + expect(statuses.at(-1)).toEqual({ _tag: "CheckingGeneration", pass: 1 }); + expect(statuses).toContainEqual({ _tag: "AuthoringGeneration", turn: 1 }); + const rendered: string[] = []; + const render = makeGenerationProgress({ + write: (value) => rendered.push(value), + mode: "compact", + presentation: "pipe", + theme: plainTheme, + onStatus: () => {}, + }); + for (const event of events) render(event); + expect(rendered).toContain("→ Started authoring the walkthrough (turn 1).\n"); + expect(rendered).toContain( + "→ Started checking the draft against the pinned source (pass 1).\n", + ); + + /* The same head again: the CLI would ask; the library refuses, naming the file. */ + const blocked = yield* Effect.flip(run({})); + expect(blocked._tag).toBe("ExistingWalkthroughUndecided"); + if (blocked._tag !== "ExistingWalkthroughUndecided") return; + expect(blocked.files).toEqual([WALKTHROUGH]); + expect(blocked.message).toContain("`force: true`"); + expect(systemPrompts).toHaveLength(1); + + /* `force` replaces, keeping the uncommitted copy; trusting instructions is explicit. */ + events.length = 0; + const replaced = yield* run({ force: true, headInstructions: "trust-changed" }); + expect(replaced._tag).toBe("Generated"); + expect(replaced.superseded).toEqual([ + { file: WALKTHROUGH, retainedAt: `${WALKTHROUGH}.superseded` }, + ]); + expect(existsSync(join(clone.dir, `${WALKTHROUGH}.superseded`))).toBe(true); + expect(systemPrompts[1]).toContain("PINNED HEAD INSTRUCTIONS"); + expect( + events.flatMap((event) => (event._tag === "AuthorNotice" ? [event.code] : [])), + ).toEqual(["head-instructions-trusted"]); + }), + ); + + it.effect("resolves the model without a picker, before the pull request is touched", () => + Effect.gen(function* () { + const harness = yield* Effect.promise(() => piHarness(true, undefined, libraryShell)); + const nowhere = join(tmpdir(), "balade-library-no-repository"); + const unresolved = yield* Effect.flip( + generateWalkthrough({ + repository: nowhere, + pullRequest: 42, + model: { providerId: "faux", modelId: "missing" }, + }).pipe(Effect.provide(harness.layer)), + ); + expect(unresolved._tag).toBe("AgentModelUnresolved"); + if (unresolved._tag !== "AgentModelUnresolved") return; + expect(unresolved.requested).toBe("faux/missing"); + expect(unresolved.available.map((model) => model.modelId)).toContain("faux-1"); + expect(unresolved.message).toContain("Available: faux/faux-1"); + + /* No saved preference to fall back on: the same refusal, never a prompt. */ + const unsaved = yield* Effect.flip( + generateWalkthrough({ repository: nowhere, pullRequest: 42 }).pipe( + Effect.provide(harness.layer), + ), + ); + expect(unsaved._tag).toBe("AgentModelUnresolved"); + if (unsaved._tag !== "AgentModelUnresolved") return; + expect(unsaved.requested).toBe("the saved model preference"); + + const unauthenticated = yield* Effect.promise(() => + piHarness(false, undefined, libraryShell), + ); + const missing = yield* Effect.flip( + generateWalkthrough({ repository: nowhere, pullRequest: 42, model: FAUX_MODEL }).pipe( + Effect.provide(unauthenticated.layer), + ), + ); + expect(missing._tag).toBe("NoProviderAuthenticated"); + expect(missing.message).toContain("balade agent setup"); + }), + ); + + it("rejects the promise with the tagged error carrying the CLI's sentence", async () => { + await expect(generate({ pullRequest: "not-a-pull-request" })).rejects.toMatchObject({ + _tag: "PullTargetInvalid", + target: "not-a-pull-request", + message: expect.stringContaining("Name one GitHub pull request"), + }); + await expect(generate({ pullRequest: 42, preset: "nope" })).rejects.toMatchObject({ + _tag: "PresetUnknown", + preset: "nope", + available: expect.arrayContaining(["odoo"]), + }); + }); + + it.effect("checks and builds one file through the command pipelines", () => + Effect.gen(function* () { + const origin = yield* fixture; + const file = join(origin.dir, origin.addWalkthrough("valid.md", "valid.md")); + const report = yield* checkWalkthrough(file, { useGh: false }); + expect(report.ok).toBe(true); + expect(report.file).toBe("walkthroughs/valid.md"); + expect(report.ranges.length).toBeGreaterThan(0); + + const bundleDir = yield* stubBundle; + const out = join(origin.dir, "review.html"); + const built = yield* buildWalkthrough(file, { out, useGh: false, bundleDir }); + expect(built._tag).toBe("Built"); + if (built._tag !== "Built") return; + expect(built.file).toBe(out); + expect(readFileSync(out, "utf8")).toContain("window.__BALADE__="); + + const unreadable = yield* Effect.flip( + buildWalkthrough(join(origin.dir, "missing.md"), { useGh: false, bundleDir }), + ); + expect(unreadable._tag).toBe("WalkthroughFileReadFailed"); + expect(unreadable.message).toContain("could not read"); + }).pipe(Effect.provide(resolverLayer)), + ); +}); diff --git a/test/support/pi.ts b/test/support/pi.ts new file mode 100644 index 0000000..9a61f6a --- /dev/null +++ b/test/support/pi.ts @@ -0,0 +1,55 @@ +/** Pi's faux provider behind the real author layer, on in-memory stores and a throwaway snapshot cache. */ + +import * as ai from "@earendil-works/pi-ai"; +import * as coding from "@earendil-works/pi-coding-agent"; +import type { NodeServices } from "@effect/platform-node"; +import { Layer } from "effect"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { contextResolverLive } from "../../src/git/git.js"; +import { piWalkthroughAuthorLayer } from "../../src/pi/client.js"; +import type { CommandExecutor } from "../../src/shell.js"; +import { shellLayer } from "./effect.js"; + +const harnessCleanups: Array<() => void> = []; + +/** Runs `cleanup` with the current test's harness cleanups. */ +export function deferCleanup(cleanup: () => void): void { + harnessCleanups.push(cleanup); +} + +/** Removes every snapshot cache the current test's harnesses created; call it from `afterEach`. */ +export function releasePiHarnesses(): void { + for (const cleanup of harnessCleanups.splice(0)) cleanup(); +} + +export async function piHarness( + registerFaux = true, + settingsManager = coding.SettingsManager.inMemory(), + shell: Layer.Layer = shellLayer, +) { + const snapshotCacheRoot = mkdtempSync(join(tmpdir(), "balade-pi-snapshots-")); + harnessCleanups.push(() => + rmSync(snapshotCacheRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }), + ); + const credentials = new ai.InMemoryCredentialStore(); + const modelRuntime = await coding.ModelRuntime.create({ + credentials, + modelsPath: null, + allowModelNetwork: false, + }); + const faux = ai.fauxProvider(); + if (registerFaux) { + modelRuntime.registerNativeProvider(faux.provider); + await modelRuntime.refresh({ allowNetwork: false }); + } + const layer = Layer.mergeAll( + piWalkthroughAuthorLayer({ + snapshotCacheRoot, + load: async () => ({ coding, ai, modelRuntime, settingsManager }), + }), + contextResolverLive, + ).pipe(Layer.provideMerge(shell)); + return { credentials, faux, layer, modelRuntime, settingsManager, snapshotCacheRoot }; +} diff --git a/tsconfig.build.json b/tsconfig.build.json index 469ac55..8635027 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -2,6 +2,7 @@ "extends": "./tsconfig.json", "compilerOptions": { "noEmit": false, + "declaration": true, "outDir": "dist", "rootDir": "src" }, From 50085cdd665999d73cfaa68669cff033d317a05f Mon Sep 17 00:00:00 2001 From: Philippe L'ATTENTION Date: Sat, 29 Aug 2026 12:44:06 +0200 Subject: [PATCH 2/3] test: assert the library's output path by suffix, not by canonical spelling Windows resolves the fixture's temp directory to an 8.3 short name (RUNNER~1) through realpathSync while git reports the long one; the assertion now checks the file lands under the clone's requested directory. Claude-Session: https://claude.ai/code/session_01QiAQotDRMwQigFYV9NXv5i --- test/library.test.ts | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/test/library.test.ts b/test/library.test.ts index 6292f4c..80861c2 100644 --- a/test/library.test.ts +++ b/test/library.test.ts @@ -7,14 +7,7 @@ import * as ai from "@earendil-works/pi-ai"; import { NodeServices } from "@effect/platform-node"; import { execFileSync } from "node:child_process"; import { Effect, Layer } from "effect"; -import { - existsSync, - mkdtempSync, - readFileSync, - realpathSync, - rmSync, - writeFileSync, -} from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "@effect/vitest"; @@ -131,8 +124,11 @@ describe("the library entry", () => { /* Default policy: the changed AGENTS.md is skipped and reported; nothing asked. */ const first = yield* run({}); expect(first._tag).toBe("Generated"); - /* Paths come back canonical, the way git reports the root. */ - expect(first.file).toBe(join(realpathSync(clone.dir), WALKTHROUGH)); + /* Absolute under the clone; the root's spelling is git's canonical one, not the fixture's. */ + expect( + first.file.endsWith(join(".agents", "walkthroughs", "pr-42-live-planning-pool.md")), + ).toBe(true); + expect(existsSync(join(clone.dir, WALKTHROUGH))).toBe(true); expect(readFileSync(first.file, "utf8")).toContain(`commit: ${pin}`); expect(first.repairs).toBe(0); expect(first.usage.total).toBeGreaterThan(0); From d9e13b9f316069939e6ebff75415e28dd609a8eb Mon Sep 17 00:00:00 2001 From: Philippe L'ATTENTION Date: Sat, 29 Aug 2026 15:11:42 +0200 Subject: [PATCH 3/3] refactor: return the CLI's GenerationResult verbatim; messages beside their errors Review pass on the library entry. `notices` now rides in the pipeline's GenerationSummary, so `generate` returns GenerationResult unchanged instead of an intersection type over the union. The agent-model message functions move from agent/terminal.ts to agent/model.ts, beside the errors they describe: the library no longer imports the interactive adapter for three sentences. The Pi test harness takes an options object instead of positional flags. Claude-Session: https://claude.ai/code/session_01QiAQotDRMwQigFYV9NXv5i --- DECISIONS.md | 3 ++- scripts/npm-smoke.sh | 4 ++-- src/agent/model.ts | 40 +++++++++++++++++++++++++++++++ src/agent/terminal.ts | 38 ----------------------------- src/commands/agent/index.ts | 7 ++++-- src/commands/generate/index.ts | 2 +- src/commands/generate/pipeline.ts | 4 ++++ src/library.ts | 18 +++++--------- test/generate.test.ts | 6 ++--- test/library.test.ts | 6 ++--- test/support/pi.ts | 20 ++++++++++------ 11 files changed, 79 insertions(+), 69 deletions(-) diff --git a/DECISIONS.md b/DECISIONS.md index 38a4f07..08fa7e4 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -101,7 +101,8 @@ A rejected promise carries the tagged error itself — `_tag`, fields, its `message` at the boundary (`withMessage`), because the error classes are shared with the CLI, whose messages live at *its* boundary, and `message` is what every promise consumer reads. The result is the CLI's `GenerationResult` -plus the pull-request `notices` the command prints as warnings. +unchanged; the pull-request `notices` the command prints as warnings ride in +it, so a script sees a degraded `gh` the way an operator does. The build emits declarations (`tsconfig.build.json`, `declaration: true`), which forced one explicit return type in `src/pi/inspection.ts`: Pi's tool diff --git a/scripts/npm-smoke.sh b/scripts/npm-smoke.sh index 7df1d66..4b11597 100644 --- a/scripts/npm-smoke.sh +++ b/scripts/npm-smoke.sh @@ -88,9 +88,9 @@ grep -Fqi "Not inside a git repository" "$GENERATE_STDERR" # consumer project, rejections are the tagged errors with a sentence attached, # and a finished call leaves nothing keeping the process alive. cat >"$PROJECT/consumer.ts" <<'EOF_CONSUMER' -import { build, check, generate, type GenerateResult } from "balade"; +import { build, check, generate, type GenerationResult } from "balade"; -export async function walkthrough(pullRequest: number): Promise { +export async function walkthrough(pullRequest: number): Promise { const result = await generate({ pullRequest, model: { providerId: "openai-codex", modelId: "gpt-5.4" }, diff --git a/src/agent/model.ts b/src/agent/model.ts index 568c219..fdd4c3a 100644 --- a/src/agent/model.ts +++ b/src/agent/model.ts @@ -1,6 +1,7 @@ /** Provider/model lifecycle shared by generation, live Q&A, setup and logout. */ import { Context, Effect, Option, Schema, Semaphore } from "effect"; +import { sanitizeTerminalText } from "../terminal.js"; import { AuthorModel as AuthorModelSchema, type AuthorLoginMethod, @@ -348,3 +349,42 @@ function loginRank(method: AuthorLoginMethod): number { function requestedModel(filter: ModelFilter): string { return `${filter.providerId ?? "any provider"}/${filter.modelId ?? "any model"}`; } + +/** The sentences every boundary — terminal or promise — prints for these failures. */ +export function noProviderMessage(requested: string): string { + return ( + `No authenticated agent model matches ${requested}. ` + + "Run `balade agent setup` interactively to authenticate and choose one." + ); +} + +export function loginErrorMessage(error: LoginFailed): string { + switch (error.reason) { + case "oauth": + return `The ${error.provider} subscription login did not complete. Retry \`balade agent setup\`.`; + case "auth": + return `The ${error.provider} credential was rejected. Check the account or API key and retry \`balade agent setup\`.`; + case "provider": + return `The ${error.provider} provider could not start. Check its configuration and retry \`balade agent setup\`.`; + case "unknown": + return `The ${error.provider} provider could not authenticate. Retry \`balade agent setup\`.`; + } +} + +export function agentModelErrorMessage(error: AgentModelError): string { + switch (error._tag) { + case "AuthorDiscoveryFailed": + return "Agent providers and models could not be loaded. Check the installation and try again."; + case "LoginFailed": + return loginErrorMessage(error); + case "LoginCancelled": + case "AgentModelSelectionCancelled": + return "Agent setup cancelled."; + case "NoProviderAuthenticated": + return noProviderMessage(error.requested); + case "AuthorCredentialReadFailed": + return "Stored agent logins could not be read. Check ~/.balade/pi/auth.json and try again."; + case "AuthorLogoutFailed": + return `The stored ${sanitizeTerminalText(error.provider)} login could not be removed. Check ~/.balade/pi/auth.json and try again.`; + } +} diff --git a/src/agent/terminal.ts b/src/agent/terminal.ts index b98f76a..f65ef51 100644 --- a/src/agent/terminal.ts +++ b/src/agent/terminal.ts @@ -181,44 +181,6 @@ function announceModel(model: AuthorModel, source?: string): void { if (model.providerId === "anthropic") writeStdout(`${anthropicBillingCaveat()}\n`); } -export function noProviderMessage(requested: string): string { - return ( - `No authenticated agent model matches ${requested}. ` + - "Run `balade agent setup` interactively to authenticate and choose one." - ); -} - -export function loginErrorMessage(error: import("../pi/author.js").LoginFailed): string { - switch (error.reason) { - case "oauth": - return `The ${error.provider} subscription login did not complete. Retry \`balade agent setup\`.`; - case "auth": - return `The ${error.provider} credential was rejected. Check the account or API key and retry \`balade agent setup\`.`; - case "provider": - return `The ${error.provider} provider could not start. Check its configuration and retry \`balade agent setup\`.`; - case "unknown": - return `The ${error.provider} provider could not authenticate. Retry \`balade agent setup\`.`; - } -} - -export function agentModelErrorMessage(error: import("./model.js").AgentModelError): string { - switch (error._tag) { - case "AuthorDiscoveryFailed": - return "Agent providers and models could not be loaded. Check the installation and try again."; - case "LoginFailed": - return loginErrorMessage(error); - case "LoginCancelled": - case "AgentModelSelectionCancelled": - return "Agent setup cancelled."; - case "NoProviderAuthenticated": - return noProviderMessage(error.requested); - case "AuthorCredentialReadFailed": - return "Stored agent logins could not be read. Check ~/.balade/pi/auth.json and try again."; - case "AuthorLogoutFailed": - return `The stored ${sanitizeTerminalText(error.provider)} login could not be removed. Check ~/.balade/pi/auth.json and try again.`; - } -} - function anthropicBillingCaveat(): string { return ( "Anthropic subscription login in third-party tools is billed per token as extra usage; " + diff --git a/src/commands/agent/index.ts b/src/commands/agent/index.ts index c9b8f5b..8de5400 100644 --- a/src/commands/agent/index.ts +++ b/src/commands/agent/index.ts @@ -2,8 +2,11 @@ import { Effect } from "effect"; import { Command, Flag } from "effect/unstable/cli"; -import { AgentModelManager, modelSelectionFromFlags } from "../../agent/model.js"; -import { agentModelErrorMessage } from "../../agent/terminal.js"; +import { + AgentModelManager, + agentModelErrorMessage, + modelSelectionFromFlags, +} from "../../agent/model.js"; import { stdoutTheme, stopMessage, writeStdout } from "../../terminal.js"; const provider = Flag.string("provider").pipe( diff --git a/src/commands/generate/index.ts b/src/commands/generate/index.ts index c23c9f0..9d3aa03 100644 --- a/src/commands/generate/index.ts +++ b/src/commands/generate/index.ts @@ -5,10 +5,10 @@ import { Effect, Option, Schema, Terminal } from "effect"; import { Argument, Command, Flag, Prompt } from "effect/unstable/cli"; import { AgentModelManager, + agentModelErrorMessage, modelSelectionFromFlags, type AgentModelConfigurationError, } from "../../agent/model.js"; -import { agentModelErrorMessage } from "../../agent/terminal.js"; import { AUTHORING_PACKAGE_VERSION } from "../../authoring/package.js"; import { langOfMeta } from "../../contract/schema.js"; import type { Lang } from "../../contract/types.js"; diff --git a/src/commands/generate/pipeline.ts b/src/commands/generate/pipeline.ts index 735c3e0..e9fda8b 100644 --- a/src/commands/generate/pipeline.ts +++ b/src/commands/generate/pipeline.ts @@ -8,6 +8,7 @@ import { checkOne } from "../../walkthrough/checker.js"; import { discoveryErrorMessage } from "../../walkthrough/discovery.js"; import { CheckReport as CheckReportSchema } from "../../contract/schema.js"; import type { Lang, CheckReport } from "../../contract/types.js"; +import type { PullNotice } from "../../git/intent.js"; import type { PullHeadError, PullSnapshot } from "../../git/pr.js"; import { DraftMalformed, @@ -78,6 +79,8 @@ interface GenerationSummary { readonly repairs: number; readonly siblings: readonly string[]; readonly superseded: readonly SupersededWalkthrough[]; + /** What resolving the pull request wanted the operator to know — the CLI prints these as warnings. */ + readonly notices: readonly PullNotice[]; readonly timing: GenerationTiming; } @@ -167,6 +170,7 @@ export const runGeneration = Effect.fn("runGeneration")((options: RunGenerationO repairs, siblings: output.siblings, superseded: output.superseded, + notices: options.source.notices, timing: progress.finish(), }; return report.ok diff --git a/src/library.ts b/src/library.ts index cf8bf1d..70aacf0 100644 --- a/src/library.ts +++ b/src/library.ts @@ -10,11 +10,11 @@ import { NodeServices } from "@effect/platform-node"; import { Effect, Layer, Option, Schema } from "effect"; import { + agentModelErrorMessage, resolveAgentModel, type AgentModelResolutionError, type ExplicitModel, } from "./agent/model.js"; -import { agentModelErrorMessage } from "./agent/terminal.js"; import type { InspectionTier } from "./authoring/package.js"; import { buildErrorMessage, @@ -37,7 +37,6 @@ import type { GenerationProgress } from "./commands/generate/progress.js"; import { langOfMeta } from "./contract/schema.js"; import type { CheckReport, Lang } from "./contract/types.js"; import { contextResolverLive } from "./git/git.js"; -import type { PullNotice } from "./git/intent.js"; import { parsePrTarget, resolvePullHead } from "./git/pr.js"; import { WalkthroughAuthor, @@ -133,9 +132,6 @@ export interface GenerateOptions { readonly onProgress?: (event: GenerationProgress) => void; } -/** The CLI's result, plus the pull-request notices it would have printed as warnings. */ -export type GenerateResult = GenerationResult & { readonly notices: readonly PullNotice[] }; - export type GenerateError = | PullTargetInvalid | PresetUnknown @@ -159,10 +155,9 @@ const noProgress = (): void => {}; */ export const generateWalkthrough = Effect.fn("generateWalkthrough")( function* (options: GenerateOptions) { - const target = parsePrTarget(String(options.pullRequest)); - if (target === null) { - return yield* new PullTargetInvalid({ target: String(options.pullRequest) }); - } + const reference = String(options.pullRequest); + const target = parsePrTarget(reference); + if (target === null) return yield* new PullTargetInvalid({ target: reference }); const preset = options.preset === undefined ? undefined : getPreset(options.preset); if (options.preset !== undefined && preset === undefined) { return yield* new PresetUnknown({ preset: options.preset, available: presetNames() }); @@ -189,7 +184,7 @@ export const generateWalkthrough = Effect.fn("generateWalkthrough")( if (options.lang !== undefined) facets.lang = options.lang; if (options.guidance !== undefined) facets.guidance = options.guidance; if (options.budget !== undefined) facets.budget = options.budget; - const result = yield* runGeneration({ + return yield* runGeneration({ source, model, directory, @@ -198,12 +193,11 @@ export const generateWalkthrough = Effect.fn("generateWalkthrough")( progress: options.onProgress ?? noProgress, ...facets, }); - return { ...result, notices: source.notices } satisfies GenerateResult; }, Effect.mapError((error) => withMessage(error, generateErrorMessage(error))), ); -export function generate(options: GenerateOptions): Promise { +export function generate(options: GenerateOptions): Promise { return Effect.runPromise(generateWalkthrough(options).pipe(Effect.provide(liveLayer))); } diff --git a/test/generate.test.ts b/test/generate.test.ts index caade0a..08dfd6b 100644 --- a/test/generate.test.ts +++ b/test/generate.test.ts @@ -532,7 +532,7 @@ describe("the Pi adapter", () => { throw new Error("settings unavailable"); }, }); - const harness = yield* Effect.promise(() => piHarness(true, brokenSettings)); + const harness = yield* Effect.promise(() => piHarness({ settingsManager: brokenSettings })); const failures = yield* Effect.gen(function* () { const author = yield* WalkthroughAuthor; const read = yield* Effect.flip(author.modelPreference); @@ -678,7 +678,7 @@ describe("the Pi adapter", () => { it.effect("exposes missing authentication without reading the user's Pi store", () => Effect.gen(function* () { - const harness = yield* Effect.promise(() => piHarness(false)); + const harness = yield* Effect.promise(() => piHarness({ faux: false })); const { methods, models } = yield* Effect.gen(function* () { const author = yield* WalkthroughAuthor; return { @@ -701,7 +701,7 @@ describe("the Pi adapter", () => { it.effect("keeps an unavailable model as a typed startup failure", () => Effect.gen(function* () { - const harness = yield* Effect.promise(() => piHarness(false)); + const harness = yield* Effect.promise(() => piHarness({ faux: false })); const unavailable = yield* Schema.decodeUnknownEffect(AuthorModelSchema)({ providerId: "missing-provider", providerName: "Missing provider", diff --git a/test/library.test.ts b/test/library.test.ts index 80861c2..5acd7e6 100644 --- a/test/library.test.ts +++ b/test/library.test.ts @@ -99,7 +99,7 @@ describe("the library entry", () => { origin.write("AGENTS.md", "PINNED HEAD INSTRUCTIONS\n"); const pin = origin.commit("docs: instructions changed by the pull request"); const clone = yield* cloneOf(origin, 42); - const harness = yield* Effect.promise(() => piHarness(true, undefined, libraryShell)); + const harness = yield* Effect.promise(() => piHarness({ shell: libraryShell })); const systemPrompts: string[] = []; harness.faux.setResponses([ (context) => { @@ -184,7 +184,7 @@ describe("the library entry", () => { it.effect("resolves the model without a picker, before the pull request is touched", () => Effect.gen(function* () { - const harness = yield* Effect.promise(() => piHarness(true, undefined, libraryShell)); + const harness = yield* Effect.promise(() => piHarness({ shell: libraryShell })); const nowhere = join(tmpdir(), "balade-library-no-repository"); const unresolved = yield* Effect.flip( generateWalkthrough({ @@ -210,7 +210,7 @@ describe("the library entry", () => { expect(unsaved.requested).toBe("the saved model preference"); const unauthenticated = yield* Effect.promise(() => - piHarness(false, undefined, libraryShell), + piHarness({ faux: false, shell: libraryShell }), ); const missing = yield* Effect.flip( generateWalkthrough({ repository: nowhere, pullRequest: 42, model: FAUX_MODEL }).pipe( diff --git a/test/support/pi.ts b/test/support/pi.ts index 9a61f6a..5495b05 100644 --- a/test/support/pi.ts +++ b/test/support/pi.ts @@ -2,6 +2,7 @@ import * as ai from "@earendil-works/pi-ai"; import * as coding from "@earendil-works/pi-coding-agent"; +import type { SettingsManager } from "@earendil-works/pi-coding-agent"; import type { NodeServices } from "@effect/platform-node"; import { Layer } from "effect"; import { mkdtempSync, rmSync } from "node:fs"; @@ -24,11 +25,16 @@ export function releasePiHarnesses(): void { for (const cleanup of harnessCleanups.splice(0)) cleanup(); } -export async function piHarness( - registerFaux = true, - settingsManager = coding.SettingsManager.inMemory(), - shell: Layer.Layer = shellLayer, -) { +export interface PiHarnessOptions { + /** `false` leaves no provider registered: the authenticated-model list is empty. */ + readonly faux?: boolean; + readonly settingsManager?: SettingsManager; + /** The shell the author layer runs on; swap it to fake `gh` or the file system. */ + readonly shell?: Layer.Layer; +} + +export async function piHarness(options: PiHarnessOptions = {}) { + const settingsManager = options.settingsManager ?? coding.SettingsManager.inMemory(); const snapshotCacheRoot = mkdtempSync(join(tmpdir(), "balade-pi-snapshots-")); harnessCleanups.push(() => rmSync(snapshotCacheRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }), @@ -40,7 +46,7 @@ export async function piHarness( allowModelNetwork: false, }); const faux = ai.fauxProvider(); - if (registerFaux) { + if (options.faux !== false) { modelRuntime.registerNativeProvider(faux.provider); await modelRuntime.refresh({ allowNetwork: false }); } @@ -50,6 +56,6 @@ export async function piHarness( load: async () => ({ coding, ai, modelRuntime, settingsManager }), }), contextResolverLive, - ).pipe(Layer.provideMerge(shell)); + ).pipe(Layer.provideMerge(options.shell ?? shellLayer)); return { credentials, faux, layer, modelRuntime, settingsManager, snapshotCacheRoot }; }