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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion .github/workflows/cli-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,20 @@ jobs:
node plugins/aidd-telemetry/hooks/journal.js turn-end < payload.json
find aidd_docs/runs -type f | xargs -I{} cat {}

- name: 'Round trip - #707 the journal stays private AND git add -A still works'
id: rt2acl
continue-on-error: true
run: |
echo "--- icacls aidd_docs/runs (directory ACL) ---"
icacls aidd_docs/runs
runfile=$(find aidd_docs/runs -maxdepth 1 -type f -name '*.jsonl' | head -1)
echo "--- icacls $runfile (journal file ACL) ---"
icacls "$runfile"
echo "--- git add -A, in the checkout that just wrote the journal above ---"
git add -A
echo "git add -A exit code: $?"
git status --porcelain

- name: Round trip - read it back
id: rt3
continue-on-error: true
Expand Down Expand Up @@ -299,7 +313,7 @@ jobs:
for outcome in \
"${{ steps.answer1.outcome }}" "${{ steps.answer2.outcome }}" \
"${{ steps.answer3bash.outcome }}" "${{ steps.answer3pwsh.outcome }}" \
"${{ steps.rt1.outcome }}" "${{ steps.rt2.outcome }}" "${{ steps.rt3.outcome }}" "${{ steps.rt4.outcome }}" \
"${{ steps.rt1.outcome }}" "${{ steps.rt2.outcome }}" "${{ steps.rt2acl.outcome }}" "${{ steps.rt3.outcome }}" "${{ steps.rt4.outcome }}" \
"${{ steps.pluginsuite.outcome }}" \
"${{ steps.cliunit.outcome }}" "${{ steps.cliintegration.outcome }}" "${{ steps.clie2e.outcome }}"; do
if [ "$outcome" = "failure" ]; then
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { join } from "node:path";
import { join, posix } from "node:path";
import { flatHooksSharedDirPath } from "../../../../domain/formats/flat-paths.js";
import { InstallationFile } from "../../../../domain/models/file.js";
import type { Manifest } from "../../../../domain/models/manifest.js";
Expand Down Expand Up @@ -128,7 +128,10 @@ export class BuiltTreeMaterializationTranslator implements PluginTranslator {
const rel = abs.slice(pluginSrc.length + 1);
const content = await this.fs.readFile(abs);
return new InstallationFile({
relativePath: join(name, rel),
// relativePath is always "/"-separated (see withoutHooksPrefix and
// belongsToPlugin below, both string-matching on "/") - node:path's platform
// `join` would answer with "\" on win32, breaking both.
relativePath: posix.join(name, rel),
content,
hash: this.hasher.hash(content),
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,13 @@ export class EnsureBuiltMarketplaceUseCase {
) {}

async execute(options: EnsureBuiltMarketplaceOptions): Promise<EnsureBuiltMarketplaceResult> {
const builtDir = builtMarketplaceDir(
options.projectRoot,
options.marketplace.name,
options.target
// resolve(), matching sourceDir below: builtMarketplaceDir() joins with the platform
// separator, and on Windows a drive-less projectRoot yields a drive-less builtDir here
// while FrameworkBuildUseCase.execute() resolves its own outDir copy for validation only
// - leaving FlatBuildStrategy's write target (captured unresolved at construction) to
// diverge from the path that gets checked.
const builtDir = resolve(
builtMarketplaceDir(options.projectRoot, options.marketplace.name, options.target)
);
const resolved = await this.resolveMarketplace.execute({
marketplace: options.marketplace,
Expand Down Expand Up @@ -112,11 +115,16 @@ export class EnsureBuiltMarketplaceUseCase {
await this.runBuild(target, mode, sourceDir, builtDir);
}

// "/"-separated comparison, matching FrameworkBuildUseCase.guardPaths(): both dirs are
// native-separated (resolve() returns "\" on Windows), so a hardcoded "/" suffix would
// never match, silently missing genuine nesting.
private nested(sourceDir: string, builtDir: string): boolean {
const normalizedSource = sourceDir.replace(/\\/g, "/");
const normalizedBuilt = builtDir.replace(/\\/g, "/");
return (
sourceDir === builtDir ||
builtDir.startsWith(`${sourceDir}/`) ||
sourceDir.startsWith(`${builtDir}/`)
normalizedSource === normalizedBuilt ||
normalizedBuilt.startsWith(`${normalizedSource}/`) ||
normalizedSource.startsWith(`${normalizedBuilt}/`)
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* measurements.md, Phase 7.
*/

import { join } from "node:path";
import { join, posix } from "node:path";
import { describe, expect, it } from "vitest";
import { PluginAddUseCase } from "../../../../src/application/use-cases/plugin/plugin-add-use-case.js";
import { PluginDistributionReaderAdapter } from "../../../../src/infrastructure/adapters/plugin-distribution-reader-adapter.js";
Expand Down Expand Up @@ -48,9 +48,16 @@ describe("PluginAddUseCase OpenCode hooks install (Phase 7)", () => {
it("writes every hooks/ script but the manifest under .opencode/plugin/", async () => {
const { deps } = await installSamplePlugin();

// deps.fs is the in-memory adapter, whose listUnder() returns its own "/"-normalised
// keys regardless of host platform - a native `join` would answer with "\" on win32
// and never match one of those keys.
const writtenPaths = deps.fs.listUnder(PROJECT_ROOT);
expect(writtenPaths).toContain(join(PROJECT_ROOT, ".opencode", "plugin", "update_memory.js"));
expect(writtenPaths).not.toContain(join(PROJECT_ROOT, ".opencode", "plugin", "hooks.json"));
expect(writtenPaths).toContain(
posix.join(PROJECT_ROOT, ".opencode", "plugin", "update_memory.js")
);
expect(writtenPaths).not.toContain(
posix.join(PROJECT_ROOT, ".opencode", "plugin", "hooks.json")
);
});

it("emits no logger.warn — hooks are delivered, not skipped", async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,20 @@ describe("install claude plugin via Mode A (integration)", () => {
const settings = JSON.parse(await fs.readFile(settingsPath)) as Record<string, unknown>;
expect(settings.extraKnownMarketplaces).toBeDefined();
// Settings reference the BUILT claude tree, not the raw source.
// marketplace-sync-settings-use-case.ts's resolveSourceForSettings re-resolves every
// local source against projectRoot before writing it out, even one already absolute -
// harmless when that path already carries a drive letter, as a real builtDir always
// does, but a genuinely drive-less absolute local path (e.g. a marketplaces.json
// committed on POSIX and read on Windows) would silently gain the current drive
// instead of failing loud. Named, not fixed here - the expectation below goes through
// the same call so this test does not mask that behavior as something else.
// (#707: this is what surfaced it - the fake ensureBuilt stand-in these tests share
// returns exactly that drive-less shape.)
expect((settings.extraKnownMarketplaces as Record<string, unknown>)[MARKETPLACE_NAME]).toEqual({
source: { source: "directory", path: "/built/claude" },
source: {
source: "directory",
path: resolve(PROJECT_ROOT, "/built/claude").replace(/\\/g, "/"),
},
});
expect(settings.enabledPlugins).toBeDefined();
expect(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,20 @@ describe("install copilot plugin via Mode A (integration)", () => {
const settings = JSON.parse(await fs.readFile(settingsPath)) as Record<string, unknown>;
expect(settings.extraKnownMarketplaces).toBeDefined();
// Settings reference the BUILT copilot tree, not the raw github source.
// marketplace-sync-settings-use-case.ts's resolveSourceForSettings re-resolves every
// local source against projectRoot before writing it out, even one already absolute -
// harmless when that path already carries a drive letter, as a real builtDir always
// does, but a genuinely drive-less absolute local path (e.g. a marketplaces.json
// committed on POSIX and read on Windows) would silently gain the current drive
// instead of failing loud. Named, not fixed here - the expectation below goes through
// the same call so this test does not mask that behavior as something else.
// (#707: this is what surfaced it - the fake ensureBuilt stand-in these tests share
// returns exactly that drive-less shape.)
expect((settings.extraKnownMarketplaces as Record<string, unknown>)[MARKETPLACE_NAME]).toEqual({
source: { source: "directory", path: "/built/copilot" },
source: {
source: "directory",
path: resolve(PROJECT_ROOT, "/built/copilot").replace(/\\/g, "/"),
},
});
expect(settings.enabledPlugins).toBeDefined();
});
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import "../../../../../src/domain/tools/ai/cursor.js";
import { join } from "node:path";
import { join, posix } from "node:path";
import { describe, expect, it } from "vitest";
import { ModeBFlatMaterializationTranslator } from "../../../../../src/application/use-cases/plugin/translator/mode-b-flat-materialization-translator.js";
import { Manifest } from "../../../../../src/domain/models/manifest.js";
Expand Down Expand Up @@ -48,7 +48,10 @@ describe("install cursor plugin via Mode B (integration)", () => {
"docs"
);

const expectedBase = join(STUB_HOME, ".cursor", "plugins", "local");
// fs.listAll() returns the adapter's own "/"-normalised keys (in-memory-file-adapter.ts's
// `norm`) - a raw `.startsWith` needs the same convention, unlike `fs.has()` below which
// normalises internally.
const expectedBase = posix.join(STUB_HOME, ".cursor", "plugins", "local");
const writtenPaths = fs.listAll();
expect(writtenPaths.some((p) => p.startsWith(expectedBase))).toBe(true);
});
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { join, resolve, sep } from "node:path";
import { beforeEach, describe, expect, it } from "vitest";
import { FrameworkBuildUseCase } from "../../../../src/application/use-cases/framework/framework-build-use-case.js";
import { FlatBuildStrategy } from "../../../../src/application/use-cases/framework/strategies/flat-build-strategy.js";
Expand Down Expand Up @@ -50,7 +50,11 @@ function stubAssetProvider(): AssetProvider {
function makeIsDirectory(memFs: InMemoryFileAdapter): (path: string) => Promise<boolean> {
return async (path: string): Promise<boolean> => {
if (memFs.has(path)) return false;
const prefix = path.endsWith("/") ? path : `${path}/`;
// memFs stores every key "/"-normalised (in-memory-file-adapter.ts's own `norm`) - a
// native, backslash-separated `path` on Windows would never prefix-match one of those
// keys without the same normalisation here.
const normalized = path.replaceAll("\\", "/");
const prefix = normalized.endsWith("/") ? normalized : `${normalized}/`;
return memFs.listAll().some((k) => k.startsWith(prefix));
};
}
Expand Down Expand Up @@ -80,7 +84,9 @@ function fakeVersion(value: string): VersionReader {

describe("builtMarketplaceDir", () => {
it("places the per-target tree under .aidd/cache/built/<mkt>/<target>", () => {
expect(builtMarketplaceDir("/p", "aidd", "codex")).toBe("/p/.aidd/cache/built/aidd/codex");
expect(builtMarketplaceDir("/p", "aidd", "codex")).toBe(
join("/p", BUILT_CACHE_SUBDIR, "aidd", "codex")
);
});
});

Expand Down Expand Up @@ -121,7 +127,7 @@ describe("EnsureBuiltMarketplaceUseCase", () => {
});

it("does not rebuild when the sentinel matches (cliVer:catalogVer)", async () => {
const builtDir = builtMarketplaceDir(PROJECT, "aidd-framework", "codex");
const builtDir = resolve(builtMarketplaceDir(PROJECT, "aidd-framework", "codex"));
fs.setFile(join(builtDir, ".build-version"), "5.0.0:1.0.0");
const uc = new EnsureBuiltMarketplaceUseCase(
fs,
Expand All @@ -140,7 +146,7 @@ describe("EnsureBuiltMarketplaceUseCase", () => {
});

it("rebuilds when the CLI version changed even if catalog version is the same", async () => {
const builtDir = builtMarketplaceDir(PROJECT, "aidd-framework", "codex");
const builtDir = resolve(builtMarketplaceDir(PROJECT, "aidd-framework", "codex"));
fs.setFile(join(builtDir, ".build-version"), "4.0.0:1.0.0");
const uc = new EnsureBuiltMarketplaceUseCase(
fs,
Expand All @@ -159,7 +165,7 @@ describe("EnsureBuiltMarketplaceUseCase", () => {
});

it("always rebuilds when catalog version is undefined", async () => {
const builtDir = builtMarketplaceDir(PROJECT, "aidd-framework", "codex");
const builtDir = resolve(builtMarketplaceDir(PROJECT, "aidd-framework", "codex"));
fs.setFile(join(builtDir, ".build-version"), "5.0.0:unversioned");
const uc = new EnsureBuiltMarketplaceUseCase(
fs,
Expand Down Expand Up @@ -191,7 +197,7 @@ describe("EnsureBuiltMarketplaceUseCase", () => {
target: "codex",
mode: "marketplace",
});
expect(r.builtDir).toBe(builtMarketplaceDir(PROJECT, "aidd-framework", "codex"));
expect(r.builtDir).toBe(resolve(builtMarketplaceDir(PROJECT, "aidd-framework", "codex")));
expect(fs.getFile(join(r.builtDir, "plugins/aidd-vcs/SKILL.md"))).toBe("built content");
// temp dir cleaned up
expect(fs.listUnder(tmpdir()).length).toBe(0);
Expand Down Expand Up @@ -225,7 +231,11 @@ describe("force behavior at the cache-rebuild path", () => {
const memFs = new InMemoryFileAdapter();
await seedFromDirectory(memFs, FIXTURE_DIR, { useAbsolutePaths: true });

const builtDir = builtMarketplaceDir(PROJECT, "aidd-framework", "copilot");
// EnsureBuiltMarketplaceUseCase.execute() resolves builtDir before handing it to
// buildFor(), so FlatBuildStrategy's write target (absOut) is always drive-qualified on
// Windows - mirror that resolve() here so this test's drive-less PROJECT seeds the same
// path the real write lands on.
const builtDir = resolve(builtMarketplaceDir(PROJECT, "aidd-framework", "copilot"));
const agentPath = `${builtDir}/.github/agents/${PLUGIN}-code-reviewer.agent.md`;
memFs.setFile(agentPath, "stale cache content from a previous build");

Expand Down Expand Up @@ -321,14 +331,18 @@ describe("outDir invariant for the cache-rebuild build path", () => {
});

expect(capturedOutDirs).toHaveLength(2);
const cacheRoot = join(PROJECT, BUILT_CACHE_SUBDIR);
// resolve(): execute() always resolves builtDir before this capture sees it (#707).
const cacheRoot = resolve(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}/`);
const underCache = outDir === cacheRoot || outDir.startsWith(`${cacheRoot}${sep}`);
const underTmp = outDir === tmpRoot || outDir.startsWith(`${tmpRoot}${sep}`);
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);
// The dogfood call specifically must have gone through the temp dir, not the cache
// (#707): nested() now compares "/"-normalized paths, and both sourceDir and builtDir
// are resolve()'d before it sees them, so a drive-less-vs-drive-qualified or
// "\"-vs-"/" mismatch can no longer hide real nesting on Windows.
expect(capturedOutDirs[1]?.startsWith(`${tmpRoot}${sep}`)).toBe(true);
});
});
24 changes: 21 additions & 3 deletions cli/tests/golden/framework-build-golden.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,14 +52,26 @@ const FLAT_TARGETS = ["claude", "cursor", "copilot", "codex", "opencode"] as con
*/
const FROZEN_CELLS = new Set(["claude"]);

// This repo carries no .gitattributes, so a Windows checkout's core.autocrlf converts
// every text file's LF to CRLF on write to disk (#707) - hashing those raw bytes would
// diff on line endings alone against the LF-committed stored baseline. Fold CRLF -> LF
// before hashing; skip anything that doesn't round-trip through UTF-8 (this tree's
// outputs are all .md/.json/.yml/.js today) so a future binary asset isn't corrupted.
function normalizeLineEndings(content: Buffer): Buffer {
const text = content.toString("utf-8");
if (Buffer.byteLength(text, "utf-8") !== content.length) return content;
return Buffer.from(text.replace(/\r\n/g, "\n"), "utf-8");
}

async function hashDirectory(dir: string): Promise<TargetSnapshot> {
const result: TargetSnapshot = {};
const entries = await readdir(dir, { recursive: true });
for (const entry of entries) {
const fullPath = join(dir, entry);
try {
const content = await readFile(fullPath);
result[entry.replace(/\\/g, "/")] = createHash("sha256").update(content).digest("hex");
const normalized = normalizeLineEndings(content);
result[entry.replace(/\\/g, "/")] = createHash("sha256").update(normalized).digest("hex");
} catch {
// skip directories
}
Expand Down Expand Up @@ -113,6 +125,10 @@ async function captureAllCells(
}

describe.concurrent("Framework build golden — 9-cell matrix", () => {
// Two full 9-cell builds, concurrently with the other tests in this file - on a real
// windows-latest runner this measured at 60039ms and 60096ms, just over the 60s default,
// not a hang (#707 windows-probe, attempt 3, run 32596840364). Raised per-test rather
// than the e2e project's global testTimeout so every other e2e file's budget is unchanged.
it("snapshot is deterministic (two captures of each target are byte-identical)", async () => {
const env1 = await createTestEnv("fb-golden-det-1");
const env2 = await createTestEnv("fb-golden-det-2");
Expand Down Expand Up @@ -155,8 +171,10 @@ describe.concurrent("Framework build golden — 9-cell matrix", () => {
await env1.cleanup();
await env2.cleanup();
}
});
}, 120_000);

// Same reason as above: a full 9-cell build, concurrently with the other tests in this
// file, measured at 60096ms on a real windows-latest runner - just over the 60s default.
it("stored golden baseline covers all 9 cells and the frozen claude cell is byte-identical (AC #1)", async () => {
const { tempDir, projectDir, fakeHome, cleanup } = await createTestEnv("fb-golden-baseline");
try {
Expand Down Expand Up @@ -190,7 +208,7 @@ describe.concurrent("Framework build golden — 9-cell matrix", () => {
} finally {
await cleanup();
}
});
}, 120_000);

it("all 9 cells are non-empty", async () => {
const stored = JSON.parse(await readFile(SNAPSHOT_FILE, "utf-8")) as GoldenSnapshot;
Expand Down
Loading
Loading