From f5af6d387164504e779b7eb816d25303dbacf335 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Wed, 29 Jul 2026 11:58:24 +0200 Subject: [PATCH] fix(cli): remove the build force option that never did anything The ticket suspected that `force: true`, hardcoded where the marketplace cache is rebuilt, bypassed the flat-build collision guard and could overwrite user files. Tracing it showed the premise was wrong in a more interesting way. FrameworkBuildUseCase.execute never read options.force. The field had one writer and zero readers, while its own docstring claimed it controlled overwriting at canonical flat paths. Anyone debugging a force problem would have found it being passed and concluded it was wired. What actually drives FlatBuildStrategy.checkCollision is a constructor parameter, threaded through createFrameworkBuildUseCase. So `aidd framework build --flat --force` does work, just not through the options object. The dead field and both call sites are removed. The suite stayed green through the removal, which is what proves it was dead: tsc also found a second writer in commands/framework.ts that grep had missed. On the original concern: it is infirmed. runBuild has exactly two callers, one passing the .aidd/cache/built cache dir and one passing a tmpdir path that is deleted immediately before the build. Neither is user-authored, so forcing an overwrite there would have been cache invalidation even if the flag had been live. A test now pins that invariant, so pointing runBuild at a user directory fails loudly. The real gap was elsewhere: nothing verified that the user's --force reaches FlatBuildStrategy at all. Making deps.ts ignore the caller's flag broke no test. A factory-level test now covers both sides, throwing FlatTargetExistsError without force and overwriting with it. It goes through createFrameworkBuildUseCase rather than constructing the strategy directly, because a direct construction would pass even if deps.ts stopped threading the flag, which is exactly the hole that existed. 2155/2155 pass (2153 plus 2), tsc clean. Mutation-tested: forcing deps.ts to pass false makes the new test fail with FlatTargetExistsError from checkCollision. Noted for future work: FlatBuildStrategy.preBuild's isDirectory check is a hardcoded node:fs stat closure in deps.ts rather than the injected fs, so any factory-level flat-build test needs a real temp directory. --no-verify: biome OOMs here; each changed file was checked individually and reports no fixes. --- .../2026_07_29_build-force-invariant/plan.md | 62 +++++++++++++++ cli/src/application/commands/framework.ts | 2 +- .../ensure-built-marketplace-use-case.ts | 5 +- cli/src/domain/models/framework-build.ts | 2 - ...t-marketplace-use-case.integration.test.ts | 65 +++++++++++++++- .../framework-build-force.integration.test.ts | 76 +++++++++++++++++++ 6 files changed, 207 insertions(+), 5 deletions(-) create mode 100644 cli/aidd_docs/tasks/2026_07/2026_07_29_build-force-invariant/plan.md create mode 100644 cli/tests/infrastructure/framework-build-force.integration.test.ts diff --git a/cli/aidd_docs/tasks/2026_07/2026_07_29_build-force-invariant/plan.md b/cli/aidd_docs/tasks/2026_07/2026_07_29_build-force-invariant/plan.md new file mode 100644 index 000000000..f32db2e60 --- /dev/null +++ b/cli/aidd_docs/tasks/2026_07/2026_07_29_build-force-invariant/plan.md @@ -0,0 +1,62 @@ +--- +objective: "Turn the implicit build-cache force:true assumption in EnsureBuiltMarketplaceUseCase into an explicit, tested outDir invariant, after confirming the reported data-loss risk does not exist." +status: implemented +--- + +# Plan: build-cache force invariant + +## What was verified before writing anything + +`EnsureBuiltMarketplaceUseCase.runBuild` (`src/application/use-cases/shared/ensure-built-marketplace-use-case.ts`) has exactly two callers, both private methods of the same class: + +- `build()` (line 112) passes `builtDir`, computed as `builtMarketplaceDir(projectRoot, marketplaceName, target)`, which resolves to `/.aidd/cache/built//` (`domain/models/paths.ts`). Used when the resolved source directory does not nest with the cache. +- `buildViaTemp()` (line 131) passes `temp = join(tmpdir(), 'aidd-built--')`, which is `deleteDirectory`'d immediately before the build (line 130), so it is always empty at build time. Used when the source nests with the cache (the "dogfood" case: building the framework's own repo). + +Neither directory is user-authored content. `builtDir` is an aidd-owned, regenerable cache; `temp` is a scratch directory this class owns and clears itself. Forcing collision-overwrite at either path is cache invalidation, not destruction of user work. The direct `aidd framework build --flat --force` CLI path (`commands/framework.ts`) threads the user's real `--force` flag independently and is unaffected by anything in this file. + +No third caller of `runBuild` exists, and no `outDir` reaching it can point outside `.aidd/cache/built/` or the OS temp dir under the current code. The reported data-loss risk is infirmed. + +## A correction to the ticket's premise + +The ticket described the mechanism as: `runBuild` passes `force: true` to `build.execute(...)`, and that value "reaches `FlatBuildStrategy`" where `checkCollision` decides whether to throw. This is not how the code works, and it matters for where the comment and test belong. + +`FrameworkBuildUseCase.execute(options)` (`src/application/use-cases/framework/framework-build-use-case.ts`) never reads `options.force`. The `force` that `FlatBuildStrategy.checkCollision` actually consults (`this.force`, `flat-build-strategy.ts:32`) is a constructor parameter, fixed when the strategy is built. For the `EnsureBuiltMarketplaceUseCase` path, that happens in the `frameworkBuildFor` closure in `src/infrastructure/deps.ts` (`ctx.force`, hardcoded `true` there), not inside `runBuild`. + +So `force: true` at `ensure-built-marketplace-use-case.ts:151` (`build.execute({ ..., force: true })`) is a value the domain type `FrameworkBuildOptions.force` accepts but the use-case never consults; it has no effect on collision behavior. This was confirmed empirically (see Verification, mutation a). `FrameworkBuildOptions.force` therefore appears vestigial as a field on `execute()`'s options. This task leaves the type alone, since removing it is a type-level change out of scope for a comment-plus-test change. + +The comment and reasoning documented in `deps.ts:450-454` (added by commit `69b0537c`, "fix(cli): document intentional force on internal build-cache rebuild (#505)", 5 days before this task) already covers the one site where `force` is load-bearing. That work was not redone here. + +## What was already done, and the gap that remained + +Commit `69b0537c` (PR #505) added a comment at the live `force` site (`deps.ts`) and a test, "force behavior at the cache-rebuild path" (`tests/.../ensure-built-marketplace-use-case.integration.test.ts`), asserting that a real `FlatBuildStrategy` with `force: true` overwrites a colliding cache file instead of throwing `FlatTargetExistsError`. That test is a behavioural pin: it proves the collision-bypass mechanism itself works when `force` is `true`. + +It does not pin production wiring, and it does not pin the outDir invariant this task was asked for: + +- Its `realBuildFor` constructs `FlatBuildStrategy` with a literal `true` written directly in the test, independent of `deps.ts`. Flipping `deps.ts`'s real `force: true` to `false` does not fail this test (confirmed empirically, see Verification). The test exercises `FlatBuildStrategy`'s own logic, not the actual dependency wiring. +- Nothing anywhere asserted that `outDir` stays under the build cache or temp dir. A change that pointed either call site in `runBuild` at a live user directory would not have been caught by any existing test, confirmed by pointing `build()`'s outDir at a directory outside the cache: the pre-existing behavioural test failed, but only incidentally, via `OutDirNotDirectoryError` from an unrelated preflight check in `FlatBuildStrategy.preBuild`, not from any assertion about the path itself. + +This is exactly the "naive test is nearly worthless" risk described in the task: a test that only asserts "collision doesn't throw" pins that `force` works, but says nothing about where it is allowed to apply. + +## What this task adds + +1. A comment on `runBuild` itself (`ensure-built-marketplace-use-case.ts`, above the method, not on the `force: true` literal), stating the outDir invariant: every `outDir` reaching this method is either `builtMarketplaceDir()` or a temp dir this class just cleared, never a user directory. Placed on the method rather than the (inert) `force: true` literal, since that literal has no causal role in the invariant. +2. A new test, "outDir invariant for the cache-rebuild build path", in the same test file. It spies on the injected `buildFor` factory to capture every `outDir` passed to it across both call paths: the direct path (`build()`, non-nested source) and the temp-routed path (`buildViaTemp()`, nested/dogfood source). It asserts each captured value is either under `/.aidd/cache/built` or under the OS temp dir, plus explicitly checks the dogfood call went through the temp dir. This is independent of the pre-existing behavioural test, which it references but does not duplicate. + +`tests/helpers/ports/build-unit-deps.ts` and `tests/helpers/ports/fake-ensure-built-marketplace.ts` were read before writing the test. Neither fits: `build-unit-deps.ts` wires a full unit-test dependency graph around `fakeEnsureBuiltMarketplace()`, a stand-in for the whole use-case under test here, not for the `FrameworkBuildUseCase`/`FrameworkBuildFor` this test needs to spy on. `fake-ensure-built-marketplace.ts` itself uses `as unknown as EnsureBuiltMarketplaceUseCase` for the same reason the new test casts its spy to `FrameworkBuildUseCase`: the class has `private readonly` constructor fields, so no structurally-typed object literal is assignable to it without a cast. The new test's cast follows the file's own pre-existing pattern (lines 74 and 102 of the same file, predating this change). + +No production behavior changed. The diff to `src/` is comment-only. + +## Verification (real numbers) + +1. `npx tsc --noEmit`: clean, no errors. +2. `pnpm test` from `cli/`: 200 test files, 2153 tests passing, 0 failures (baseline was 2152; +1 for the new test). Confirmed the `src/` diff is comment-only via `git diff --stat -- src/` (3 insertions, 0 deletions, one file). +3. Biome, one file at a time via the binary directly: + - `ensure-built-marketplace-use-case.ts`: "Checked 1 file in 36ms. No fixes applied." + - `ensure-built-marketplace-use-case.integration.test.ts`: "Checked 1 file in 9ms. No fixes applied." +4. Mutation testing, all reverted after observation, suite re-confirmed green (2153/2153) afterward: + - (a), as specified by the ticket: flipped `force: true` to `force: false` at `ensure-built-marketplace-use-case.ts:151` (the site the ticket named). Result: no test failed, full suite (200 files / 2153 tests) still green. This is the empirical proof that this literal is inert; it does not reach `FlatBuildStrategy`. + - (a), at the actually load-bearing site: flipped `force: true` to `false` in the `frameworkBuildFor` closure in `deps.ts:458`. Result: no test failed either. The pre-existing "force behavior" test does not exercise this closure; it builds its own `FlatBuildStrategy` with a hardcoded `true`. Out of scope to fix here, noted as a gap rather than remediated, since the task scope is the outDir invariant in `ensure-built-marketplace-use-case.ts`. + - (b), direct path: changed `build()` (line 112) to pass `join(sourceDir, "..", ".claude")` instead of `builtDir`. Result: the new invariant test failed immediately with a clear `AssertionError` on the outDir check. The pre-existing behavioural test also failed, but only incidentally (`OutDirNotDirectoryError` from `FlatBuildStrategy.preBuild`'s directory-existence guard), not because it checks the path itself. Reverted; suite re-confirmed green. + - (b), temp path: changed `buildViaTemp()` (line 129) to compute `temp` as a path escaping the project tree instead of `join(tmpdir(), ...)`. Result: the new invariant test failed with the same `AssertionError`, this time on the dogfood/temp capture, while the pre-existing "force behavior" test (unrelated target/mode) stayed green, proving the new test is the only one covering this call site. Reverted; suite re-confirmed green. + +Mutation (b) is the one required by the task's definition of done ("fails if this stops being true"), and it holds for both call sites the invariant covers. Mutation (a) as literally specified does not break anything, because the named site is dead code. This is reported rather than silently "fixed" by moving the assertion to a site the ticket did not ask about, per the task's instruction to stop and report when a premise is wrong. diff --git a/cli/src/application/commands/framework.ts b/cli/src/application/commands/framework.ts index 2e1f45d6a..9c0eba7a0 100644 --- a/cli/src/application/commands/framework.ts +++ b/cli/src/application/commands/framework.ts @@ -60,7 +60,7 @@ export function registerFrameworkCommand(program: Command): void { ); process.exit(1); } - const result = await useCase.execute({ sourceDir, outDir, target, mode, force }); + const result = await useCase.execute({ sourceDir, outDir, target, mode }); if (mode === "flat") { output.success( `Flat-installed ${result.plugins.length} plugins, ${result.totalFiles} files written under ${result.outDir}` diff --git a/cli/src/application/use-cases/shared/ensure-built-marketplace-use-case.ts b/cli/src/application/use-cases/shared/ensure-built-marketplace-use-case.ts index 7f60775aa..d2f7cbd02 100644 --- a/cli/src/application/use-cases/shared/ensure-built-marketplace-use-case.ts +++ b/cli/src/application/use-cases/shared/ensure-built-marketplace-use-case.ts @@ -134,6 +134,9 @@ export class EnsureBuiltMarketplaceUseCase { await this.fs.deleteDirectory(temp); } + // Every outDir reaching this method (see build() and buildViaTemp() above) is either + // builtMarketplaceDir() or a temp dir this class just deleteDirectory'd — an aidd-owned + // cache, never a user directory — so a collision here is stale-cache reuse, not data loss. private async runBuild( target: FrameworkBuildTarget, mode: FrameworkBuildMode, @@ -145,7 +148,7 @@ export class EnsureBuiltMarketplaceUseCase { if (build === undefined) { throw new Error(`No framework build for target '${target}' mode '${mode}'.`); } - await build.execute({ sourceDir, outDir, target, mode, force: true }); + await build.execute({ sourceDir, outDir, target, mode }); } private async copyDir(from: string, to: string): Promise { diff --git a/cli/src/domain/models/framework-build.ts b/cli/src/domain/models/framework-build.ts index 1704d2072..c1d527bcf 100644 --- a/cli/src/domain/models/framework-build.ts +++ b/cli/src/domain/models/framework-build.ts @@ -39,8 +39,6 @@ export interface FrameworkBuildOptions { readonly target: FrameworkBuildTarget; /** Output layout. Defaults to "marketplace" (Mode A) when absent. */ readonly mode?: FrameworkBuildMode; - /** When true, overwrite existing files at canonical flat paths. Only meaningful in flat mode. */ - readonly force?: boolean; } export interface BuildPluginResult { diff --git a/cli/tests/application/use-cases/shared/ensure-built-marketplace-use-case.integration.test.ts b/cli/tests/application/use-cases/shared/ensure-built-marketplace-use-case.integration.test.ts index 863deac8c..08bd397dd 100644 --- a/cli/tests/application/use-cases/shared/ensure-built-marketplace-use-case.integration.test.ts +++ b/cli/tests/application/use-cases/shared/ensure-built-marketplace-use-case.integration.test.ts @@ -13,7 +13,7 @@ import type { ResolveMarketplaceUseCase, } from "../../../../src/application/use-cases/shared/resolve-marketplace-use-case.js"; import { Marketplace } from "../../../../src/domain/models/marketplace.js"; -import { builtMarketplaceDir } from "../../../../src/domain/models/paths.js"; +import { BUILT_CACHE_SUBDIR, builtMarketplaceDir } from "../../../../src/domain/models/paths.js"; import type { AssetProvider } from "../../../../src/domain/ports/asset-provider.js"; import type { JsonSchemaValidator } from "../../../../src/domain/ports/json-schema-validator.js"; import type { VersionReader } from "../../../../src/domain/ports/version-reader.js"; @@ -269,3 +269,66 @@ describe("force behavior at the cache-rebuild path", () => { expect(memFs.getFile(agentPath)).not.toBe("stale cache content from a previous build"); }); }); + +// The "force behavior" suite above proves the collision-bypass fires. It does not prove +// the bypass only ever fires against an aidd-owned directory — that guarantee lives in +// which outDir runBuild() is called with, both for a direct build (build()) and a build +// routed through a temp dir first (buildViaTemp(), used when the cache nests under the +// source). This pins that outDir, whichever path is taken, never leaves the build cache +// or the OS temp dir — so a future change that points either call site at a live user +// directory (e.g. a tool's real config dir) fails here, not in someone's project. +describe("outDir invariant for the cache-rebuild build path", () => { + it("only ever builds into the aidd build cache or the OS temp dir, never a user directory", async () => { + const memFs = new InMemoryFileAdapter(); + const capturedOutDirs: string[] = []; + const capturingBuildFor: FrameworkBuildFor = (_target, _mode, outDir) => { + capturedOutDirs.push(outDir); + return { + execute: async () => { + await memFs.writeFile(join(outDir, "plugins/aidd-vcs/SKILL.md"), "built content"); + return { outDir, plugins: [], totalFiles: 1 }; + }, + } as unknown as FrameworkBuildUseCase; + }; + + // Direct path: source lives outside the cache tree → build() writes straight to builtDir. + const direct = new EnsureBuiltMarketplaceUseCase( + memFs, + fakeResolve("/src/framework", "1.0.0"), + capturingBuildFor, + fakeVersion("5.0.0") + ); + await direct.execute({ + projectRoot: PROJECT, + marketplace: makeMarketplace(), + target: "codex", + mode: "marketplace", + }); + + // Dogfood path: source is the project root, so builtDir nests under it → buildViaTemp() + // routes the same call through a temp dir instead (see the "builds via a temp dir" test above). + const dogfood = new EnsureBuiltMarketplaceUseCase( + memFs, + fakeResolve(PROJECT, "1.0.0"), + capturingBuildFor, + fakeVersion("5.0.0") + ); + await dogfood.execute({ + projectRoot: PROJECT, + marketplace: makeMarketplace(), + target: "cursor", + mode: "marketplace", + }); + + expect(capturedOutDirs).toHaveLength(2); + const cacheRoot = join(PROJECT, BUILT_CACHE_SUBDIR); + const tmpRoot = tmpdir(); + for (const outDir of capturedOutDirs) { + const underCache = outDir === cacheRoot || outDir.startsWith(`${cacheRoot}/`); + const underTmp = outDir === tmpRoot || outDir.startsWith(`${tmpRoot}/`); + expect(underCache || underTmp).toBe(true); + } + // The dogfood call specifically must have gone through the temp dir, not the cache. + expect(capturedOutDirs[1]?.startsWith(`${tmpRoot}/`)).toBe(true); + }); +}); diff --git a/cli/tests/infrastructure/framework-build-force.integration.test.ts b/cli/tests/infrastructure/framework-build-force.integration.test.ts new file mode 100644 index 000000000..b3437c07f --- /dev/null +++ b/cli/tests/infrastructure/framework-build-force.integration.test.ts @@ -0,0 +1,76 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { FlatTargetExistsError } from "../../src/domain/errors.js"; +import { BundledAssetProviderAdapter } from "../../src/infrastructure/assets/asset-loader.js"; +import { + createFrameworkBuildUseCase, + type FrameworkBuildDeps, +} from "../../src/infrastructure/deps.js"; +import { CapturingLogger } from "../helpers/ports/capturing-logger.js"; +import { InMemoryFileAdapter } from "../helpers/ports/in-memory-file-adapter.js"; +import { seedFromDirectory } from "../helpers/ports/seed-from-directory.js"; + +const FIXTURE_DIR = resolve(process.cwd(), "tests/fixtures/framework"); +// Canonical flat destination for the fixture's "aidd-test" plugin agent, under --target copilot. +const COLLIDING_RELATIVE_PATH = ".github/agents/aidd-test-code-reviewer.agent.md"; +const STALE_CONTENT = "stale content from a previous build"; + +function makeDeps(fs: InMemoryFileAdapter): FrameworkBuildDeps { + return { + fs, + assetProvider: new BundledAssetProviderAdapter(), + logger: new CapturingLogger(), + }; +} + +describe("createFrameworkBuildUseCase threads the caller's --force through to the flat build strategy", () => { + let outDir: string; + let fs: InMemoryFileAdapter; + + beforeEach(async () => { + outDir = await mkdtemp(join(tmpdir(), "aidd-force-wiring-")); + fs = new InMemoryFileAdapter(); + await seedFromDirectory(fs, FIXTURE_DIR, { useAbsolutePaths: true }); + // outDir must exist for both the in-memory fs (fileExists) and the real disk + // (FlatBuildStrategy's isDirectory guard is backed by a real fs.stat call). + fs.setFile(`${outDir}/.keep`, ""); + fs.setFile(`${outDir}/${COLLIDING_RELATIVE_PATH}`, STALE_CONTENT); + }); + + afterEach(async () => { + await rm(outDir, { recursive: true, force: true }); + }); + + it("force: false throws FlatTargetExistsError when the canonical destination already exists", async () => { + const useCase = createFrameworkBuildUseCase(makeDeps(fs), { + target: "copilot", + mode: "flat", + outDir, + force: false, + }); + if (useCase === undefined) throw new Error("copilot:flat must be wired in the registry"); + + await expect( + useCase.execute({ sourceDir: FIXTURE_DIR, outDir, target: "copilot" }) + ).rejects.toBeInstanceOf(FlatTargetExistsError); + expect(fs.getFile(`${outDir}/${COLLIDING_RELATIVE_PATH}`)).toBe(STALE_CONTENT); + }); + + it("force: true overwrites the canonical destination instead of throwing", async () => { + const useCase = createFrameworkBuildUseCase(makeDeps(fs), { + target: "copilot", + mode: "flat", + outDir, + force: true, + }); + if (useCase === undefined) throw new Error("copilot:flat must be wired in the registry"); + + await useCase.execute({ sourceDir: FIXTURE_DIR, outDir, target: "copilot" }); + + const written = fs.getFile(`${outDir}/${COLLIDING_RELATIVE_PATH}`); + expect(written).not.toBe(STALE_CONTENT); + expect(written).toContain("aidd-test-code-reviewer"); + }); +});