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
18 changes: 17 additions & 1 deletion integration/ddd_layer_rules_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,24 @@ function isTracingImport(filePath: string, importPath: string): boolean {
// Ratchet counts: current number of known violations.
// If someone fixes a violation, the count decreases and the test still passes.
// If someone adds a new violation, the count increases and the test fails.
//
// Tracked refactor (data services → domain-side ports): swamp-club#229.
const KNOWN_DOMAIN_INFRA_VIOLATIONS = 27;
//
// Issue #223 (W1b extension catalog rearchitecture) added 4 new
// domain→infrastructure imports:
// - src/domain/extensions/bundle_location.ts → canonicalizePath
// - src/domain/extensions/source_location.ts → canonicalizePath
// - src/domain/extensions/source.ts → ExtensionKind type
// - src/domain/extensions/extension.ts → ExtensionKind type
// canonicalizePath is a pure string transform with cross-platform rules
// that the value objects need at construction time; ExtensionKind is the
// type-level discriminator the W1a catalog defines and the aggregate
// references for I-Repo-1 cross-aggregate uniqueness. Both are accepted
// as transitional ports — the canonicalizer should move to a shared
// path-utility module (W3 territory) and ExtensionKind should hoist to
// the domain layer when the catalog gets fully replaced (W4). Until
// then the violations are bounded and the ratchet rises by 4 (27 + 4).
const KNOWN_DOMAIN_INFRA_VIOLATIONS = 31;

Deno.test(
"domain layer must not add new infrastructure imports (ratchet)",
Expand Down
28 changes: 18 additions & 10 deletions src/cli/auto_resolver_adapters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ import { UserModelLoader } from "../domain/models/user_model_loader.ts";
import { UserVaultLoader } from "../domain/vaults/user_vault_loader.ts";
import { UserDatastoreLoader } from "../domain/datastore/user_datastore_loader.ts";
import type { DatastorePathResolver } from "../domain/datastore/datastore_path_resolver.ts";
import type { ExtensionCatalogStore } from "../infrastructure/persistence/extension_catalog_store.ts";
import type { ExtensionRepository } from "../infrastructure/persistence/extension_repository.ts";
import { modelRegistry } from "../domain/models/model.ts";
import type { OutputMode } from "../presentation/output/output.ts";
import {
Expand Down Expand Up @@ -82,11 +82,13 @@ interface InstallerAdapterConfig {
denoRuntime: DenoRuntime;
datastoreResolver?: DatastorePathResolver;
/**
* Shared extension catalog used by hotLoadModels to attach user
* extensions whose base type was just registered. Optional so
* existing callers that do not need the attach retry can omit it.
* W1b/(a-2) wiring: shared ExtensionRepository used by hotLoadModels
* to attach user extensions whose base type was just registered, and
* passed through to every loader's constructor so internal
* catalog operations route through `repository.legacyStore`. Optional
* so existing callers that do not need the attach retry can omit it.
*/
catalog?: ExtensionCatalogStore;
repository?: ExtensionRepository;
}

/**
Expand All @@ -104,7 +106,7 @@ export function createAutoResolveInstallerAdapter(
repoDir,
denoRuntime,
datastoreResolver,
catalog,
repository,
} = config;

return {
Expand Down Expand Up @@ -218,6 +220,7 @@ export function createAutoResolveInstallerAdapter(
denoRuntime,
repoDir,
datastoreResolver,
repository,
);
const [primary, ...rest] = pulledDirs;
const result = await loader.loadModels(primary, {
Expand All @@ -231,17 +234,21 @@ export function createAutoResolveInstallerAdapter(
// short-circuit and loadSingleType's extension-attach loop would
// never run. Walk the catalog's extension rows and attach any whose
// base is now fully loaded. Idempotent (issue 123).
if (catalog && result.loaded.length > 0) {
if (repository && result.loaded.length > 0) {
const pendingBases = new Set<string>();
for (const row of catalog.findByKind("extension")) {
// Direct catalog access via the W1b transitional escape hatch.
// W4 will rewrite this to walk aggregate state instead.
for (
const row of repository.legacyStore.findByKind("extension")
) {
// Validation-failed rows (swamp-club#209) have empty
// extends_type so they fall out of this set naturally — the
// explicit emptiness check below already filters them.
if (row.extends_type) pendingBases.add(row.extends_type);
}
for (const type of pendingBases) {
if (!modelRegistry.get(type)) continue;
await loader.attachPendingExtensionsForType(type, catalog);
await loader.attachPendingExtensionsForType(type);
}
}

Expand All @@ -259,6 +266,7 @@ export function createAutoResolveInstallerAdapter(
denoRuntime,
repoDir,
datastoreResolver,
repository,
);
const [primary, ...rest] = pulledDirs;
await loader.loadVaults(primary, {
Expand All @@ -276,7 +284,7 @@ export function createAutoResolveInstallerAdapter(
if (pulledDirs.length === 0) return;
// Bootstrap: datastore loader must NOT receive the resolver —
// it loads datastore extensions that configure the resolver.
const loader = new UserDatastoreLoader(denoRuntime, repoDir);
const loader = new UserDatastoreLoader(denoRuntime, repoDir, repository);
const [primary, ...rest] = pulledDirs;
await loader.loadDatastores(primary, {
skipAlreadyRegistered: true,
Expand Down
19 changes: 16 additions & 3 deletions src/cli/auto_resolver_adapters_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { join } from "@std/path";
import { createAutoResolveInstallerAdapter } from "./auto_resolver_adapters.ts";
import type { DenoRuntime } from "../domain/runtime/deno_runtime.ts";
import { ExtensionCatalogStore } from "../infrastructure/persistence/extension_catalog_store.ts";
import { ExtensionRepository } from "../infrastructure/persistence/extension_repository.ts";
import { modelRegistry } from "../domain/models/model.ts";
import { ModelType } from "../domain/models/model_type.ts";
import type { ModelDefinition } from "../domain/models/model.ts";
Expand All @@ -48,6 +49,18 @@ const stubCallbacks = {
getChecksum: () => Promise.resolve(null),
};

/** W1b/(a-2): construct an ExtensionRepository wrapping a test catalog. */
function makeRepoForCatalog(
catalog: ExtensionCatalogStore,
repoRoot: string,
): ExtensionRepository {
return new ExtensionRepository({
catalog,
getLockedVersion: () => null,
repoRoot,
});
}

async function seedLockfile(
repoDir: string,
entries: Record<string, string[]>,
Expand Down Expand Up @@ -577,7 +590,7 @@ Deno.test("auto_resolver_adapters: hotLoadModels skips catalog walk when catalog
lockfilePath,
repoDir: tmpDir,
denoRuntime: stubDenoRuntime,
catalog,
repository: makeRepoForCatalog(catalog, tmpDir),
});

// With stub deno the loader fails to bundle — result.loaded is 0,
Expand Down Expand Up @@ -629,7 +642,7 @@ Deno.test("auto_resolver_adapters: hotLoadModels catalog walk skips types whose
lockfilePath,
repoDir: tmpDir,
denoRuntime: stubDenoRuntime,
catalog,
repository: makeRepoForCatalog(catalog, tmpDir),
});

// Primary assertion: the call completes cleanly. If the guard
Expand Down Expand Up @@ -692,7 +705,7 @@ Deno.test("auto_resolver_adapters: hotLoadModels catalog walk attempts attach wh
lockfilePath,
repoDir: tmpDir,
denoRuntime: stubDenoRuntime,
catalog,
repository: makeRepoForCatalog(catalog, tmpDir),
});

assertEquals(await adapter.hotLoadModels(), 0);
Expand Down
50 changes: 38 additions & 12 deletions src/cli/commands/doctor_extensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,9 @@ import { vaultTypeRegistry } from "../../domain/vaults/vault_type_registry.ts";
import { driverTypeRegistry } from "../../domain/drivers/driver_type_registry.ts";
import { datastoreTypeRegistry } from "../../domain/datastore/datastore_type_registry.ts";
import { reportRegistry } from "../../domain/reports/report_registry.ts";
import { forceCatalogRescan } from "../../infrastructure/persistence/extension_catalog_store.ts";
import { ExtensionCatalogStore } from "../../infrastructure/persistence/extension_catalog_store.ts";
import { ExtensionRepository } from "../../infrastructure/persistence/extension_repository.ts";
import { swampPath } from "../../infrastructure/persistence/paths.ts";
import { createDoctorExtensionsRenderer } from "../../presentation/renderers/doctor_extensions.ts";
import {
createContext,
Expand Down Expand Up @@ -98,11 +100,41 @@ export const doctorExtensionsCommand = new Command()
// Same gate as `doctor audit` — fails loudly outside a swamp repo.
await resolveDatastoreForRepo(repoDir);

// Resolve lockfile path early so the rescan repository's
// empty-version fallback has lockfile entries available. (Hoisted
// from the post-rescan section per ADV-2 resolution; the same
// values are reused below for orphan detection.)
const repoPath = RepoPath.create(repoDir);
const markerRepo = new RepoMarkerRepository();
const marker = await markerRepo.read(repoPath);
const modelsDir = resolveModelsDir(marker);
const absoluteModelsDir = isAbsolute(modelsDir)
? modelsDir
: resolve(repoDir, modelsDir);
const lockfilePath = join(absoluteModelsDir, "upstream_extensions.json");

// Invalidate the catalog so the loaders run a full re-validation
// instead of returning the cached lazy entries. Without this, the
// doctor reports stale results when run after another swamp
// command in the same repo.
forceCatalogRescan(repoDir);
// W1b: forceCatalogRescan(repoDir) → repository.invalidateAll().
try {
const upstream = await readUpstreamExtensions(lockfilePath);
const rescanRepo = new ExtensionRepository({
catalog: new ExtensionCatalogStore(
swampPath(repoDir, "_extension_catalog.db"),
),
getLockedVersion: (name) => upstream[name]?.version ?? null,
repoRoot: repoDir,
});
try {
rescanRepo.invalidateAll();
} finally {
rescanRepo.legacyStore.close();
}
} catch {
// Best-effort — the loader will bootstrap a fresh catalog if this fails.
}

const registries: ReadonlyArray<DoctorRegistryDeps> = [
{
Expand Down Expand Up @@ -132,16 +164,10 @@ export const doctorExtensionsCommand = new Command()
},
];

// Resolve lockfile and skills paths so the orphan-detection phase
// can walk the per-extension roots referenced by the lockfile.
const repoPath = RepoPath.create(repoDir);
const markerRepo = new RepoMarkerRepository();
const marker = await markerRepo.read(repoPath);
const modelsDir = resolveModelsDir(marker);
const absoluteModelsDir = isAbsolute(modelsDir)
? modelsDir
: resolve(repoDir, modelsDir);
const lockfilePath = join(absoluteModelsDir, "upstream_extensions.json");
// Resolve skills paths so the orphan-detection phase can walk the
// per-extension roots referenced by the lockfile. (lockfilePath /
// marker / repoPath / modelsDir / absoluteModelsDir are hoisted
// above the rescan call earlier in this function.)
const tool = resolvePrimaryTool(marker);
const absoluteSkillsDir = resolveSkillsDir(repoDir, tool);
// detectOrphanFiles wants a repo-relative skills dir so it can
Expand Down
38 changes: 36 additions & 2 deletions src/cli/commands/open.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,11 @@ import { pullExtension } from "./extension_pull.ts";
import { RepoPath } from "../../domain/repo/repo_path.ts";
import { RepoMarkerRepository } from "../../infrastructure/persistence/repo_marker_repository.ts";
import { resolveModelsDir } from "../resolve_models_dir.ts";
import { forceCatalogRescan } from "../../infrastructure/persistence/extension_catalog_store.ts";
import { ExtensionCatalogStore } from "../../infrastructure/persistence/extension_catalog_store.ts";
import { ExtensionRepository } from "../../infrastructure/persistence/extension_repository.ts";
import { readUpstreamExtensions } from "../../infrastructure/persistence/upstream_extensions.ts";
import { swampPath } from "../../infrastructure/persistence/paths.ts";
import { isAbsolute } from "@std/path";
import {
configureExtensionAutoResolver,
configureExtensionLoaders,
Expand Down Expand Up @@ -106,7 +110,37 @@ async function loadRepoIntoState(
const deferred: DeferredWarning[] = [];
await configureExtensionLoaders(result.repoDir, marker, [], deferred);
configureExtensionAutoResolver(result.repoDir, marker, undefined, outputMode);
forceCatalogRescan(result.repoDir);

// W1b: forceCatalogRescan(repoDir) → repository.invalidateAll(). The
// lockfile is read upfront so the empty-version fallback path has
// entries available; readUpstreamExtensions returns {} on NotFound,
// making the closure return null for every name (correct for a
// missing lockfile). Best-effort: any failure to invalidate is
// logged and swallowed so the open path doesn't crash on a missing
// or corrupt catalog DB.
try {
const modelsDir = resolveModelsDir(marker);
const absoluteModelsDir = isAbsolute(modelsDir)
? modelsDir
: resolve(result.repoDir, modelsDir);
const lockfilePath = join(absoluteModelsDir, "upstream_extensions.json");
const upstream = await readUpstreamExtensions(lockfilePath);
const rescanRepo = new ExtensionRepository({
catalog: new ExtensionCatalogStore(
swampPath(result.repoDir, "_extension_catalog.db"),
),
getLockedVersion: (name) => upstream[name]?.version ?? null,
repoRoot: result.repoDir,
});
try {
rescanRepo.invalidateAll();
} finally {
rescanRepo.legacyStore.close();
}
} catch {
// Best-effort — the loader will bootstrap a fresh catalog if this fails.
}

await reloadExtensionRegistries();
}

Expand Down
Loading
Loading