From 325c301acd324c757054748db0acac0c14b0a744 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adel=20Rodr=C3=ADguez?= Date: Thu, 1 Oct 2026 14:14:25 -0400 Subject: [PATCH 1/7] Make lib folder dependencies point in a single direction Move on-disk paths and the Packref home into a new layout folder so store and workspace no longer import each other. Split core/packages into identity, repository host, and spec modules to break the errors/packages cycle. Add a layer test that enforces the folder order and rejects import cycles. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/architecture.md | 22 +++- src/__tests__/add.integration.test.ts | 2 +- src/commands/add.ts | 3 +- src/commands/list.ts | 2 +- src/commands/prune.ts | 2 +- src/commands/remove.ts | 3 +- src/commands/sync.ts | 2 +- src/lib/__tests__/layers.test.ts | 114 ++++++++++++++++++ src/lib/core/__tests__/packages.test.ts | 5 +- src/lib/core/errors.ts | 2 +- src/lib/core/identity.ts | 34 ++++++ src/lib/core/packages.ts | 63 ++-------- src/lib/core/repository.ts | 21 ++++ src/lib/{workspace => layout}/home.ts | 0 src/lib/{workspace => layout}/paths.ts | 15 ++- src/lib/references/__tests__/add.test.ts | 2 +- src/lib/references/__tests__/install.test.ts | 2 +- src/lib/references/__tests__/prune.test.ts | 4 +- src/lib/references/__tests__/sync.test.ts | 2 +- src/lib/references/add.ts | 7 +- src/lib/references/clean.ts | 2 +- src/lib/references/install.ts | 6 +- src/lib/references/prune.ts | 2 +- src/lib/references/remove.ts | 9 +- src/lib/references/sync.ts | 13 +- src/lib/registries/registry.ts | 3 +- .../repository/__tests__/fetch.test.ts | 2 +- .../sources/repository/__tests__/tags.test.ts | 3 +- src/lib/sources/repository/fetch.ts | 2 +- src/lib/sources/repository/normalize.ts | 23 ++-- src/lib/sources/repository/tags.ts | 2 +- .../sources/tarball/__tests__/fetch.test.ts | 2 +- src/lib/sources/tarball/fetch.ts | 2 +- src/lib/store/__tests__/store.test.ts | 2 +- src/lib/store/index.ts | 14 +-- src/lib/store/paths.ts | 15 +-- src/lib/workspace/config.ts | 4 +- src/lib/workspace/lockfile.ts | 8 +- src/lib/workspace/project.ts | 9 +- 39 files changed, 277 insertions(+), 153 deletions(-) create mode 100644 src/lib/__tests__/layers.test.ts create mode 100644 src/lib/core/identity.ts create mode 100644 src/lib/core/repository.ts rename src/lib/{workspace => layout}/home.ts (100%) rename src/lib/{workspace => layout}/paths.ts (61%) diff --git a/docs/architecture.md b/docs/architecture.md index fbbded5..c172119 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -25,8 +25,23 @@ executable `bin/packref` imports that output. | `manifests` | Detect project manifests and resolve declared dependencies to exact versions. JavaScript is the v1 adapter. | | `sources` | Normalize, select, and fetch repository or tarball source, including source-specific external adapters. | | `store` | Address and manage immutable global source snapshots by package identity. | -| `workspace` | Own the Packref lockfile, user-level state, project paths, integrations, and reference materialization adapters. | -| `core` | Define package identity, package specs, source types, registry contracts, and shared typed errors. | +| `workspace` | Own the Packref lockfile, user-level state, integrations, and reference materialization adapters. | +| `layout` | Define the on-disk paths of project state, user-level state, and the global store, and the Packref home. | +| `core` | Define package identity, package specs, repository hosts, source types, registries, and shared typed errors. | + +### Layers + +Dependencies inside `src/lib` point in a single direction. A folder imports only folders in lower +layers, and folders in the same layer do not import each other: + +1. `core` and `shared` +2. `layout` +3. `registries`, `manifests`, `store`, and `workspace` +4. `sources` (imports `store` to write snapshots) +5. `references` + +`src/lib/__tests__/layers.test.ts` enforces these rules and rejects import cycles between files. When +you add a folder or a dependency, update `ALLOWED_DEPENDENCIES` in that test. Commands remain thin so the `references` interface is also the main behavior test seam. Registry and manifest adapters do not know filesystem layout, while source adapters do not know project manifests @@ -95,7 +110,8 @@ src/ commands/ CLI command definitions terminal/ prompts, logs, spinners, titles, and cancellation lib/ - core/ identities, sources, errors, registry contracts + core/ identities, specs, repository hosts, sources, registries, errors + layout/ on-disk paths and the Packref home manifests/ project dependency adapters references/ command-aligned domain workflows registries/ package registry adapters diff --git a/src/__tests__/add.integration.test.ts b/src/__tests__/add.integration.test.ts index 49316f5..b219bda 100644 --- a/src/__tests__/add.integration.test.ts +++ b/src/__tests__/add.integration.test.ts @@ -7,6 +7,7 @@ import * as Effect from "effect/Effect" import * as Layer from "effect/Layer" import { afterEach, describe, expect, it } from "vitest" import { parsePackageSpec } from "#lib/core/packages.ts" +import { PackrefHome } from "#lib/layout/home.ts" import { ProjectDependencyReader } from "#lib/manifests/index.ts" import { PackageManagerResolver } from "#lib/manifests/javascript.ts" import { PackageManagerDetector } from "#lib/manifests/package-manager-detector.ts" @@ -14,7 +15,6 @@ import { addPackageReference } from "#lib/references/add.ts" import { NpmRegistryClient } from "#lib/registries/npm/client.ts" import { RepositoryDownloader } from "#lib/sources/repository/fetch.ts" import { RemoteTagReader } from "#lib/sources/repository/tags.ts" -import { PackrefHome } from "#lib/workspace/home.ts" import { Reflinker } from "#lib/workspace/reflinker.ts" const temporaryPaths: string[] = [] diff --git a/src/commands/add.ts b/src/commands/add.ts index a4afaf2..8b60b18 100644 --- a/src/commands/add.ts +++ b/src/commands/add.ts @@ -5,7 +5,8 @@ import * as Effect from "effect/Effect" import * as Option from "effect/Option" import * as Predicate from "effect/Predicate" import type { ManifestDependency } from "#lib/manifests/manifest.ts" -import { formatPackageIdentity, parsePackageSpec } from "#lib/core/packages.ts" +import { formatPackageIdentity } from "#lib/core/identity.ts" +import { parsePackageSpec } from "#lib/core/packages.ts" import { addPackageReference, findPackageCandidates, diff --git a/src/commands/list.ts b/src/commands/list.ts index 1b991bb..33aa407 100644 --- a/src/commands/list.ts +++ b/src/commands/list.ts @@ -1,7 +1,7 @@ import * as Command from "effect/cli/Command" import * as Effect from "effect/Effect" import color from "picocolors" -import { formatPackageIdentity } from "#lib/core/packages.ts" +import { formatPackageIdentity } from "#lib/core/identity.ts" import { listPackageEntries, readProjectLockfile } from "#lib/workspace/lockfile.ts" import { requireInitializedProject } from "#lib/workspace/project.ts" import { Prompter } from "#terminal/prompter.ts" diff --git a/src/commands/prune.ts b/src/commands/prune.ts index 84519e3..faaf696 100644 --- a/src/commands/prune.ts +++ b/src/commands/prune.ts @@ -1,6 +1,6 @@ import * as Command from "effect/cli/Command" import * as Effect from "effect/Effect" -import { formatPackageIdentity } from "#lib/core/packages.ts" +import { formatPackageIdentity } from "#lib/core/identity.ts" import { applyPrunePlan, discoverPrunePlan } from "#lib/references/prune.ts" import { Prompter } from "#terminal/prompter.ts" import { printTitle } from "#terminal/title.ts" diff --git a/src/commands/remove.ts b/src/commands/remove.ts index fb0570a..296444b 100644 --- a/src/commands/remove.ts +++ b/src/commands/remove.ts @@ -4,7 +4,8 @@ import * as Command from "effect/cli/Command" import * as Effect from "effect/Effect" import * as Option from "effect/Option" import type { PackageEntry } from "#lib/workspace/lockfile.ts" -import { formatPackageIdentity, parsePackageSpec } from "#lib/core/packages.ts" +import { formatPackageIdentity } from "#lib/core/identity.ts" +import { parsePackageSpec } from "#lib/core/packages.ts" import { findPackageReferenceMatches, listPackageReferences, diff --git a/src/commands/sync.ts b/src/commands/sync.ts index e497b90..7b03887 100644 --- a/src/commands/sync.ts +++ b/src/commands/sync.ts @@ -1,6 +1,6 @@ import * as Command from "effect/cli/Command" import * as Effect from "effect/Effect" -import { formatPackageIdentity } from "#lib/core/packages.ts" +import { formatPackageIdentity } from "#lib/core/identity.ts" import { preparePackageReferenceSync, syncPackageReferences, diff --git a/src/lib/__tests__/layers.test.ts b/src/lib/__tests__/layers.test.ts new file mode 100644 index 0000000..e765099 --- /dev/null +++ b/src/lib/__tests__/layers.test.ts @@ -0,0 +1,114 @@ +import { readdirSync, readFileSync } from "node:fs" +import { join, relative } from "node:path" +import { describe, expect, it } from "vitest" + +/** + * Each lib folder lists the folders it may import. A folder never imports a folder at the same or a + * higher layer, so dependencies point in a single direction. + */ +const ALLOWED_DEPENDENCIES = { + core: [], + layout: ["core"], + manifests: ["core"], + references: [ + "core", + "layout", + "manifests", + "registries", + "shared", + "sources", + "store", + "workspace", + ], + registries: ["core"], + shared: [], + sources: ["core", "shared", "store"], + store: ["core", "layout", "shared"], + workspace: ["core", "layout", "shared"], +} satisfies Record + +type LibFolder = keyof typeof ALLOWED_DEPENDENCIES + +const LIB_ROOT = join(import.meta.dirname, "..") +const LIB_IMPORT_PATTERN = /from "#lib\/(?[^"]+)"/gu + +const sourceFiles = readdirSync(LIB_ROOT, { recursive: true, withFileTypes: true }) + .filter( + (entry) => + entry.isFile() + && entry.name.endsWith(".ts") + && !entry.parentPath.split(/[\\/]/u).includes("__tests__") + ) + .map((entry) => relative(LIB_ROOT, join(entry.parentPath, entry.name)).replaceAll("\\", "/")) + .toSorted() + +const importGraph = new Map( + sourceFiles.map((file) => [ + file, + [...readFileSync(join(LIB_ROOT, file), "utf8").matchAll(LIB_IMPORT_PATTERN)].map( + (match) => match.groups?.target ?? "" + ), + ]) +) + +const getFolder = (file: string) => file.split("/")[0] ?? "" + +const checkIsLibFolder = (folder: string): folder is LibFolder => + Object.hasOwn(ALLOWED_DEPENDENCIES, folder) + +describe("lib layers", () => { + it("assigns every lib folder to a layer", () => { + const folders = new Set(sourceFiles.map(getFolder)) + + expect([...folders].filter((folder) => !checkIsLibFolder(folder))).toEqual([]) + }) + + it("imports only from allowed lower layers", () => { + const violations = [...importGraph].flatMap(([file, targets]) => { + const folder = getFolder(file) + const allowed: readonly string[] = checkIsLibFolder(folder) + ? ALLOWED_DEPENDENCIES[folder] + : [] + + return targets + .filter((target) => getFolder(target) !== folder && !allowed.includes(getFolder(target))) + .map((target) => `${file} -> ${target}`) + }) + + expect(violations).toEqual([]) + }) + + it("has no import cycles between files", () => { + const cycles: string[] = [] + const visiting: string[] = [] + const visited = new Set() + + const visit = (file: string) => { + const cycleStart = visiting.indexOf(file) + + if (cycleStart !== -1) { + cycles.push([...visiting.slice(cycleStart), file].join(" -> ")) + return + } + + if (visited.has(file)) { + return + } + + visiting.push(file) + + for (const target of importGraph.get(file) ?? []) { + visit(target) + } + + visiting.pop() + visited.add(file) + } + + for (const file of sourceFiles) { + visit(file) + } + + expect(cycles).toEqual([]) + }) +}) diff --git a/src/lib/core/__tests__/packages.test.ts b/src/lib/core/__tests__/packages.test.ts index 4e9ff6a..003710c 100644 --- a/src/lib/core/__tests__/packages.test.ts +++ b/src/lib/core/__tests__/packages.test.ts @@ -7,12 +7,11 @@ import { UnsupportedRepositoryHostError, } from "#lib/core/errors.ts" import { - getPackageIdentitySegments, packageCoordinatesEquivalence, packageCoordinatesOrder, - parsePackageSpec, type PackageIdentity, -} from "#lib/core/packages.ts" +} from "#lib/core/identity.ts" +import { getPackageIdentitySegments, parsePackageSpec } from "#lib/core/packages.ts" const runEffect = (effect: Effect.Effect) => Effect.runPromise(effect) diff --git a/src/lib/core/errors.ts b/src/lib/core/errors.ts index d6fe61e..badea59 100644 --- a/src/lib/core/errors.ts +++ b/src/lib/core/errors.ts @@ -1,5 +1,5 @@ import * as Data from "effect/Data" -import type { PackageIdentity } from "#lib/core/packages.ts" +import type { PackageIdentity } from "#lib/core/identity.ts" import { SUPPORTED_REGISTRIES } from "#lib/core/registry.ts" export class MissingPackageVersion extends Data.TaggedError("MissingPackageVersion")<{ diff --git a/src/lib/core/identity.ts b/src/lib/core/identity.ts new file mode 100644 index 0000000..b55ce6b --- /dev/null +++ b/src/lib/core/identity.ts @@ -0,0 +1,34 @@ +import * as Equivalence from "effect/Equivalence" +import * as Order from "effect/Order" + +export interface PackageCoordinates { + readonly name: string + readonly registry: string +} + +export interface PackageIdentity extends PackageCoordinates { + readonly version: string +} + +export const packageCoordinatesEquivalence = Equivalence.Struct({ + name: Equivalence.String, + registry: Equivalence.String, +}) + +export const packageCoordinatesOrder = Order.combineAll([ + Order.mapInput(Order.String, (coordinates: PackageCoordinates) => coordinates.registry), + Order.mapInput(Order.String, (coordinates: PackageCoordinates) => coordinates.name), +]) + +export const packageIdentityEquivalence = Equivalence.combine( + packageCoordinatesEquivalence, + Equivalence.mapInput(Equivalence.String, (identity: PackageIdentity) => identity.version) +) + +export const packageIdentityOrder = Order.combine( + packageCoordinatesOrder, + Order.mapInput(Order.String, (identity: PackageIdentity) => identity.version) +) + +export const formatPackageIdentity = (identity: PackageIdentity) => + `${identity.registry}:${identity.name}@${identity.version}` diff --git a/src/lib/core/packages.ts b/src/lib/core/packages.ts index 1aa4cb4..a600fe7 100644 --- a/src/lib/core/packages.ts +++ b/src/lib/core/packages.ts @@ -1,45 +1,18 @@ import type * as Types from "effect/Types" import * as Effect from "effect/Effect" -import * as Equivalence from "effect/Equivalence" -import * as Order from "effect/Order" +import type { PackageIdentity } from "#lib/core/identity.ts" import { InvalidPackageIdentity, UnsupportedRegistryError, UnsupportedRepositoryHostError, } from "#lib/core/errors.ts" import { DEFAULT_REGISTRY, checkIsRegistry, type Registry } from "#lib/core/registry.ts" - -export interface PackageCoordinates { - readonly name: string - readonly registry: string -} - -export interface PackageIdentity extends PackageCoordinates { - readonly version: string -} - -export const packageCoordinatesEquivalence = Equivalence.Struct({ - name: Equivalence.String, - registry: Equivalence.String, -}) - -export const packageCoordinatesOrder = Order.combineAll([ - Order.mapInput(Order.String, (coordinates: PackageCoordinates) => coordinates.registry), - Order.mapInput(Order.String, (coordinates: PackageCoordinates) => coordinates.name), -]) - -export const packageIdentityEquivalence = Equivalence.combine( - packageCoordinatesEquivalence, - Equivalence.mapInput(Equivalence.String, (identity: PackageIdentity) => identity.version) -) - -export const packageIdentityOrder = Order.combine( - packageCoordinatesOrder, - Order.mapInput(Order.String, (identity: PackageIdentity) => identity.version) -) - -export const formatPackageIdentity = (identity: PackageIdentity) => - `${identity.registry}:${identity.name}@${identity.version}` +import { + HOST_REPOSITORY_PROVIDERS, + checkIsRepositoryProvider, + REPOSITORY_PROVIDER_HOSTS, + type RepositoryProvider, +} from "#lib/core/repository.ts" export interface RegistryPackageSpec { readonly _tag: "registry" @@ -48,14 +21,6 @@ export interface RegistryPackageSpec { readonly specifier?: string } -export const SUPPORTED_REPOSITORY_PROVIDERS = [ - "bitbucket", - "github", - "gitlab", - "sourcehut", -] as const -export type RepositoryProvider = (typeof SUPPORTED_REPOSITORY_PROVIDERS)[number] - export interface RepositoryPackageSpec { readonly _tag: "repository" readonly name: string @@ -108,20 +73,6 @@ const validatePathSegment = Effect.fn("validatePathSegment")(function* ( } }) -export const REPOSITORY_PROVIDER_HOSTS = { - bitbucket: "bitbucket.org", - github: "github.com", - gitlab: "gitlab.com", - sourcehut: "git.sr.ht", -} satisfies Record - -const HOST_REPOSITORY_PROVIDERS = new Map( - SUPPORTED_REPOSITORY_PROVIDERS.map((provider) => [REPOSITORY_PROVIDER_HOSTS[provider], provider]) -) - -const checkIsRepositoryProvider = (value: string): value is RepositoryProvider => - SUPPORTED_REPOSITORY_PROVIDERS.some((provider) => provider === value) - const splitRepositoryRef = (value: string) => { const schemeIndex = value.indexOf("://") const scpPathIndex = schemeIndex === -1 ? value.indexOf(":") : -1 diff --git a/src/lib/core/repository.ts b/src/lib/core/repository.ts new file mode 100644 index 0000000..d852b82 --- /dev/null +++ b/src/lib/core/repository.ts @@ -0,0 +1,21 @@ +export const SUPPORTED_REPOSITORY_PROVIDERS = [ + "bitbucket", + "github", + "gitlab", + "sourcehut", +] as const +export type RepositoryProvider = (typeof SUPPORTED_REPOSITORY_PROVIDERS)[number] + +export const REPOSITORY_PROVIDER_HOSTS = { + bitbucket: "bitbucket.org", + github: "github.com", + gitlab: "gitlab.com", + sourcehut: "git.sr.ht", +} satisfies Record + +export const HOST_REPOSITORY_PROVIDERS = new Map( + SUPPORTED_REPOSITORY_PROVIDERS.map((provider) => [REPOSITORY_PROVIDER_HOSTS[provider], provider]) +) + +export const checkIsRepositoryProvider = (value: string): value is RepositoryProvider => + SUPPORTED_REPOSITORY_PROVIDERS.some((provider) => provider === value) diff --git a/src/lib/workspace/home.ts b/src/lib/layout/home.ts similarity index 100% rename from src/lib/workspace/home.ts rename to src/lib/layout/home.ts diff --git a/src/lib/workspace/paths.ts b/src/lib/layout/paths.ts similarity index 61% rename from src/lib/workspace/paths.ts rename to src/lib/layout/paths.ts index 8d39671..9c64e90 100644 --- a/src/lib/workspace/paths.ts +++ b/src/lib/layout/paths.ts @@ -1,4 +1,7 @@ -import type * as Path from "effect/Path" +import * as Effect from "effect/Effect" +import * as Path from "effect/Path" +import type { PackageIdentity } from "#lib/core/identity.ts" +import { getPackageIdentitySegments } from "#lib/core/packages.ts" export const PACKREF_DIRECTORY_NAME = ".packref" export const LOCKFILE_NAME = "packref-lock.json" @@ -16,3 +19,13 @@ export const getGlobalDirectoryPath = (path: Path.Path, home: string) => export const getGlobalConfigPath = (path: Path.Path, home: string) => path.join(getGlobalDirectoryPath(path, home), GLOBAL_CONFIG_NAME) + +export const getPackageIdentityPath = Effect.fn("getPackageIdentityPath")(function* ( + root: string, + identity: PackageIdentity +) { + const path = yield* Path.Path + const segments = yield* getPackageIdentitySegments(identity) + + return path.join(root, ...segments) +}) diff --git a/src/lib/references/__tests__/add.test.ts b/src/lib/references/__tests__/add.test.ts index 5a073ed..92e1b7d 100644 --- a/src/lib/references/__tests__/add.test.ts +++ b/src/lib/references/__tests__/add.test.ts @@ -15,6 +15,7 @@ import type { NpmPackageMetadata } from "#lib/registries/npm/metadata.ts" import type { PackageEntry } from "#lib/workspace/lockfile.ts" import { NetworkError, ReflinkError, SnapshotFetchError } from "#lib/core/errors.ts" import { parsePackageSpec } from "#lib/core/packages.ts" +import { PackrefHome } from "#lib/layout/home.ts" import { ProjectDependencyReader } from "#lib/manifests/index.ts" import { PackageManagerResolver } from "#lib/manifests/javascript.ts" import { @@ -26,7 +27,6 @@ import { import { NpmRegistryClient } from "#lib/registries/npm/client.ts" import { RepositoryDownloader } from "#lib/sources/repository/fetch.ts" import { RemoteTagReader } from "#lib/sources/repository/tags.ts" -import { PackrefHome } from "#lib/workspace/home.ts" import { Reflinker } from "#lib/workspace/reflinker.ts" const temporaryPaths: string[] = [] diff --git a/src/lib/references/__tests__/install.test.ts b/src/lib/references/__tests__/install.test.ts index 7ef071b..03f1c99 100644 --- a/src/lib/references/__tests__/install.test.ts +++ b/src/lib/references/__tests__/install.test.ts @@ -17,10 +17,10 @@ import { StoreSourceMismatchError, TarballFetchError, } from "#lib/core/errors.ts" +import { PackrefHome } from "#lib/layout/home.ts" import { installPackageReferences } from "#lib/references/install.ts" import { RepositoryDownloader } from "#lib/sources/repository/fetch.ts" import { RemoteTagReader } from "#lib/sources/repository/tags.ts" -import { PackrefHome } from "#lib/workspace/home.ts" import { Reflinker } from "#lib/workspace/reflinker.ts" type TestRepositorySource = Types.Mutable diff --git a/src/lib/references/__tests__/prune.test.ts b/src/lib/references/__tests__/prune.test.ts index 61df61e..2cf6e56 100644 --- a/src/lib/references/__tests__/prune.test.ts +++ b/src/lib/references/__tests__/prune.test.ts @@ -7,11 +7,11 @@ import * as NodeServices from "@effect/platform-node/NodeServices" import * as Effect from "effect/Effect" import * as Layer from "effect/Layer" import { afterEach, describe, expect, it } from "vitest" -import type { PackageIdentity } from "#lib/core/packages.ts" +import type { PackageIdentity } from "#lib/core/identity.ts" import { initializeProject, repositoryEntry } from "#commands/__tests__/helpers.ts" +import { PackrefHome } from "#lib/layout/home.ts" import { applyPrunePlan, discoverPrunePlan } from "#lib/references/prune.ts" import { getStoreEntryPath } from "#lib/store/index.ts" -import { PackrefHome } from "#lib/workspace/home.ts" const temporaryPaths: string[] = [] diff --git a/src/lib/references/__tests__/sync.test.ts b/src/lib/references/__tests__/sync.test.ts index 74d19ba..cbca28c 100644 --- a/src/lib/references/__tests__/sync.test.ts +++ b/src/lib/references/__tests__/sync.test.ts @@ -15,13 +15,13 @@ import { TarballFetchError, UnsupportedManifestError, } from "#lib/core/errors.ts" +import { PackrefHome } from "#lib/layout/home.ts" import { ProjectDependencyReader } from "#lib/manifests/index.ts" import { PackageManagerResolver } from "#lib/manifests/javascript.ts" import { preparePackageReferenceSync, syncPackageReferences } from "#lib/references/sync.ts" import { NpmRegistryClient } from "#lib/registries/npm/client.ts" import { RepositoryDownloader } from "#lib/sources/repository/fetch.ts" import { RemoteTagReader } from "#lib/sources/repository/tags.ts" -import { PackrefHome } from "#lib/workspace/home.ts" import { Reflinker } from "#lib/workspace/reflinker.ts" const temporaryPaths: string[] = [] diff --git a/src/lib/references/add.ts b/src/lib/references/add.ts index 761f131..bbe2faa 100644 --- a/src/lib/references/add.ts +++ b/src/lib/references/add.ts @@ -5,6 +5,7 @@ import * as Option from "effect/Option" import * as Order from "effect/Order" import * as Path from "effect/Path" import * as Predicate from "effect/Predicate" +import type { ParsedPackageSpec, RepositoryPackageSpec } from "#lib/core/packages.ts" import type { RepositorySource } from "#lib/core/source.ts" import type { ManifestDependency } from "#lib/manifests/manifest.ts" import type { ResolvedPackageReference } from "#lib/registries/registry.ts" @@ -16,11 +17,9 @@ import { } from "#lib/core/errors.ts" import { packageCoordinatesEquivalence, - packageIdentityEquivalence, type PackageIdentity, - type ParsedPackageSpec, - type RepositoryPackageSpec, -} from "#lib/core/packages.ts" + packageIdentityEquivalence, +} from "#lib/core/identity.ts" import { ProjectDependencyReader } from "#lib/manifests/index.ts" import { resolvePackageReference } from "#lib/registries/index.ts" import { fetchRepositorySnapshot } from "#lib/sources/repository/fetch.ts" diff --git a/src/lib/references/clean.ts b/src/lib/references/clean.ts index 0163cc2..0413ff9 100644 --- a/src/lib/references/clean.ts +++ b/src/lib/references/clean.ts @@ -2,8 +2,8 @@ import * as Effect from "effect/Effect" import * as FileSystem from "effect/FileSystem" import * as Path from "effect/Path" import { PACKAGE_DIRECTORY_NAME } from "#lib/core/packages.ts" +import { getDirectoryPath, getProjectLockfilePath } from "#lib/layout/paths.ts" import { emptyLockfile, readProjectLockfile, writeLockfileAtPath } from "#lib/workspace/lockfile.ts" -import { getDirectoryPath, getProjectLockfilePath } from "#lib/workspace/paths.ts" import { requireInitializedProject } from "#lib/workspace/project.ts" export interface ProjectCleanPlan { diff --git a/src/lib/references/install.ts b/src/lib/references/install.ts index 6db27d8..a50ed7c 100644 --- a/src/lib/references/install.ts +++ b/src/lib/references/install.ts @@ -6,7 +6,7 @@ import { StoreSourceMismatchError, UnsupportedRepositoryHostError, } from "#lib/core/errors.ts" -import { SUPPORTED_REPOSITORY_PROVIDERS } from "#lib/core/packages.ts" +import { checkIsRepositoryProvider } from "#lib/core/repository.ts" import { packageSourceEquivalence } from "#lib/core/source.ts" import { fetchRepositorySnapshot } from "#lib/sources/repository/fetch.ts" import { @@ -69,9 +69,7 @@ const fetchLockedStoreEntry = Effect.fn("fetchLockedStoreEntry")(function* (entr return yield* ensureMatchingSource(entry, materialized) } - const isDirectRepository = SUPPORTED_REPOSITORY_PROVIDERS.some( - (provider) => provider === entry.registry - ) + const isDirectRepository = checkIsRepositoryProvider(entry.registry) const resolvedRepository = isDirectRepository ? yield* Effect.gen(function* () { const source = yield* normalizeRepositorySource(entry.source) diff --git a/src/lib/references/prune.ts b/src/lib/references/prune.ts index 0eac9d3..b7eb060 100644 --- a/src/lib/references/prune.ts +++ b/src/lib/references/prune.ts @@ -3,7 +3,7 @@ import * as FileSystem from "effect/FileSystem" import * as Filter from "effect/Filter" import * as Order from "effect/Order" import { ProjectFilesystemError } from "#lib/core/errors.ts" -import { packageIdentityOrder, type PackageIdentity } from "#lib/core/packages.ts" +import { type PackageIdentity, packageIdentityOrder } from "#lib/core/identity.ts" import { listStoreEntries, removeStoreEntry, type StoreEntry } from "#lib/store/index.ts" import { initializeGlobalConfig, unregisterProjects } from "#lib/workspace/config.ts" import { readProjectLockfile } from "#lib/workspace/lockfile.ts" diff --git a/src/lib/references/remove.ts b/src/lib/references/remove.ts index dd66da8..87ed490 100644 --- a/src/lib/references/remove.ts +++ b/src/lib/references/remove.ts @@ -2,7 +2,6 @@ import type * as Types from "effect/Types" import * as Array from "effect/Array" import * as Effect from "effect/Effect" import * as FileSystem from "effect/FileSystem" -import * as Path from "effect/Path" import * as Result from "effect/Result" import type { ParsedPackageSpec } from "#lib/core/packages.ts" import { @@ -10,7 +9,6 @@ import { PackageReferenceFilesystemError, RemovePackageReferencesError, } from "#lib/core/errors.ts" -import { getStorePackagePath } from "#lib/store/paths.ts" import { findPackageEntries, listPackageEntries, @@ -18,8 +16,7 @@ import { removePackageEntries, type PackageEntry, } from "#lib/workspace/lockfile.ts" -import { getDirectoryPath } from "#lib/workspace/paths.ts" -import { requireInitializedProject } from "#lib/workspace/project.ts" +import { getProjectReferencePath, requireInitializedProject } from "#lib/workspace/project.ts" const REMOVE_CONCURRENCY = 8 @@ -79,13 +76,11 @@ export const removePackageReferences = Effect.fn("removePackageReferences")(func entries: readonly PackageEntry[] ) { const fs = yield* FileSystem.FileSystem - const path = yield* Path.Path - const projectDirectoryPath = getDirectoryPath(path, projectPath) const attempts = yield* Effect.forEach( entries, (entry) => Effect.gen(function* () { - const referencePath = yield* getStorePackagePath(projectDirectoryPath, entry) + const referencePath = yield* getProjectReferencePath(projectPath, entry) const referenceExists = yield* fs.exists(referencePath).pipe( Effect.mapError( (cause) => diff --git a/src/lib/references/sync.ts b/src/lib/references/sync.ts index e24b667..c65c6e5 100644 --- a/src/lib/references/sync.ts +++ b/src/lib/references/sync.ts @@ -2,16 +2,15 @@ import * as Array from "effect/Array" import * as Effect from "effect/Effect" import * as FileSystem from "effect/FileSystem" import * as Option from "effect/Option" -import * as Path from "effect/Path" import * as Result from "effect/Result" import type { ManifestDependency } from "#lib/manifests/manifest.ts" import { PackageReferenceFilesystemError, UnsupportedManifestError } from "#lib/core/errors.ts" import { + type PackageCoordinates, packageCoordinatesEquivalence, packageCoordinatesOrder, packageIdentityEquivalence, - type PackageCoordinates, -} from "#lib/core/packages.ts" +} from "#lib/core/identity.ts" import { ProjectDependencyReader } from "#lib/manifests/index.ts" import { materializePackageCandidateReference, @@ -19,15 +18,13 @@ import { type ResolvedPackageCandidateReference, } from "#lib/references/add.ts" import { removePackageReferences } from "#lib/references/remove.ts" -import { getStorePackagePath } from "#lib/store/paths.ts" import { registerProject } from "#lib/workspace/config.ts" import { listPackageEntries, readProjectLockfile, type PackageEntry, } from "#lib/workspace/lockfile.ts" -import { getDirectoryPath } from "#lib/workspace/paths.ts" -import { requireInitializedProject } from "#lib/workspace/project.ts" +import { getProjectReferencePath, requireInitializedProject } from "#lib/workspace/project.ts" export interface SyncPackageReferencesOptions { readonly projectPath?: string @@ -80,9 +77,7 @@ const packageReferenceExists = Effect.fn("sync.packageReferenceExists")(function entry: PackageEntry ) { const fs = yield* FileSystem.FileSystem - const path = yield* Path.Path - const projectDirectoryPath = getDirectoryPath(path, projectPath) - const referencePath = yield* getStorePackagePath(projectDirectoryPath, entry) + const referencePath = yield* getProjectReferencePath(projectPath, entry) return yield* fs.exists(referencePath).pipe( Effect.mapError( diff --git a/src/lib/registries/registry.ts b/src/lib/registries/registry.ts index 399e3c4..b7a3d76 100644 --- a/src/lib/registries/registry.ts +++ b/src/lib/registries/registry.ts @@ -1,5 +1,6 @@ import type * as Effect from "effect/Effect" -import type { PackageIdentity, RegistryPackageSpec } from "#lib/core/packages.ts" +import type { PackageIdentity } from "#lib/core/identity.ts" +import type { RegistryPackageSpec } from "#lib/core/packages.ts" import type { Registry } from "#lib/core/registry.ts" import type { RepositorySourceCandidate } from "#lib/core/source.ts" diff --git a/src/lib/sources/repository/__tests__/fetch.test.ts b/src/lib/sources/repository/__tests__/fetch.test.ts index 52a6e32..f526740 100644 --- a/src/lib/sources/repository/__tests__/fetch.test.ts +++ b/src/lib/sources/repository/__tests__/fetch.test.ts @@ -8,8 +8,8 @@ import * as Effect from "effect/Effect" import * as Layer from "effect/Layer" import { afterEach, describe, expect, it } from "vitest" import { SnapshotFetchError } from "#lib/core/errors.ts" +import { PackrefHome } from "#lib/layout/home.ts" import { fetchRepositorySnapshot, RepositoryDownloader } from "#lib/sources/repository/fetch.ts" -import { PackrefHome } from "#lib/workspace/home.ts" const temporaryPaths: string[] = [] diff --git a/src/lib/sources/repository/__tests__/tags.test.ts b/src/lib/sources/repository/__tests__/tags.test.ts index ef36c6b..c68d5e5 100644 --- a/src/lib/sources/repository/__tests__/tags.test.ts +++ b/src/lib/sources/repository/__tests__/tags.test.ts @@ -8,7 +8,8 @@ import * as Layer from "effect/Layer" import * as Option from "effect/Option" import * as PlatformError from "effect/PlatformError" import { afterEach, describe, expect, it } from "vitest" -import type { PackageIdentity, RepositoryPackageSpec } from "#lib/core/packages.ts" +import type { PackageIdentity } from "#lib/core/identity.ts" +import type { RepositoryPackageSpec } from "#lib/core/packages.ts" import type { NormalizedRepositorySource } from "#lib/core/source.ts" import { GitExecutableNotFoundError, diff --git a/src/lib/sources/repository/fetch.ts b/src/lib/sources/repository/fetch.ts index b8e1808..cda5d12 100644 --- a/src/lib/sources/repository/fetch.ts +++ b/src/lib/sources/repository/fetch.ts @@ -3,7 +3,7 @@ import * as Context from "effect/Context" import * as Effect from "effect/Effect" import * as Layer from "effect/Layer" import { downloadTemplate } from "giget" -import type { PackageIdentity } from "#lib/core/packages.ts" +import type { PackageIdentity } from "#lib/core/identity.ts" import type { RepositorySource, ResolvedRepositoryRef } from "#lib/core/source.ts" import { SnapshotFetchError } from "#lib/core/errors.ts" import { materializeStoreEntry } from "#lib/store/index.ts" diff --git a/src/lib/sources/repository/normalize.ts b/src/lib/sources/repository/normalize.ts index c790675..1b6e768 100644 --- a/src/lib/sources/repository/normalize.ts +++ b/src/lib/sources/repository/normalize.ts @@ -2,6 +2,8 @@ import type * as Types from "effect/Types" import * as Effect from "effect/Effect" import * as Match from "effect/Match" import * as Option from "effect/Option" +import type { PackageIdentity } from "#lib/core/identity.ts" +import type { RepositoryPackageSpec } from "#lib/core/packages.ts" import type { NormalizedRepositorySource, RepositorySourceCandidate, @@ -13,29 +15,22 @@ import { UnsupportedRepositoryHostError, } from "#lib/core/errors.ts" import { + checkIsRepositoryProvider, + HOST_REPOSITORY_PROVIDERS, REPOSITORY_PROVIDER_HOSTS, SUPPORTED_REPOSITORY_PROVIDERS, - type PackageIdentity, - type RepositoryPackageSpec, type RepositoryProvider, -} from "#lib/core/packages.ts" +} from "#lib/core/repository.ts" import { matchRepositoryTag, RemoteTagReader } from "#lib/sources/repository/tags.ts" const COMMIT_SHA_PATTERN = /^[0-9a-f]{40}$/iu -const HOST_PROVIDERS = new Map( - SUPPORTED_REPOSITORY_PROVIDERS.map((provider) => [REPOSITORY_PROVIDER_HOSTS[provider], provider]) -) - const DEFAULT_SHORTHAND_PROVIDER = "github" satisfies RepositoryProvider const SHORTHAND_PATTERN = new RegExp( `^(?${SUPPORTED_REPOSITORY_PROVIDERS.join("|")}):(?.+)$`, "u" ) -const checkIsKnownProvider = (value: string): value is RepositoryProvider => - SUPPORTED_REPOSITORY_PROVIDERS.some((provider) => provider === value) - const cleanRepositoryPath = (repositoryPath: string) => repositoryPath .replace(/\/+$/u, "") @@ -56,7 +51,7 @@ const makeNormalizedSource = ( return Effect.fail(invalidRepositoryUrl(candidate, "repository path must not be empty")) } - const provider = HOST_PROVIDERS.get(host) + const provider = HOST_REPOSITORY_PROVIDERS.get(host) const fetchRepositoryPath = provider === "sourcehut" ? repositoryPath.replace(/^~/u, "") : repositoryPath @@ -130,7 +125,11 @@ export const normalizeRepositorySource = Effect.fn("normalizeRepositorySource")( const provider = Option.getOrUndefined(shorthandGroups)?.provider const shorthandPath = Option.getOrUndefined(shorthandGroups)?.repositoryPath - if (provider !== undefined && shorthandPath !== undefined && checkIsKnownProvider(provider)) { + if ( + provider !== undefined + && shorthandPath !== undefined + && checkIsRepositoryProvider(provider) + ) { return normalizeFromShorthandUrl(candidate, provider, shorthandPath) } diff --git a/src/lib/sources/repository/tags.ts b/src/lib/sources/repository/tags.ts index 57d0baa..4fa632b 100644 --- a/src/lib/sources/repository/tags.ts +++ b/src/lib/sources/repository/tags.ts @@ -12,7 +12,7 @@ import { ChildProcessSpawner } from "effect/process/ChildProcessSpawner" import * as Result from "effect/Result" import * as Schedule from "effect/Schedule" import * as Stream from "effect/Stream" -import type { PackageIdentity } from "#lib/core/packages.ts" +import type { PackageIdentity } from "#lib/core/identity.ts" import type { NormalizedRepositorySource } from "#lib/core/source.ts" import { GitExecutableNotFoundError, NetworkError } from "#lib/core/errors.ts" diff --git a/src/lib/sources/tarball/__tests__/fetch.test.ts b/src/lib/sources/tarball/__tests__/fetch.test.ts index 3a313f8..d7e2ad2 100644 --- a/src/lib/sources/tarball/__tests__/fetch.test.ts +++ b/src/lib/sources/tarball/__tests__/fetch.test.ts @@ -11,8 +11,8 @@ import * as Layer from "effect/Layer" import { createTarGzip } from "nanotar" import { afterEach, describe, expect, it } from "vitest" import { TarballFetchError } from "#lib/core/errors.ts" +import { PackrefHome } from "#lib/layout/home.ts" import { fetchTarballSnapshot } from "#lib/sources/tarball/fetch.ts" -import { PackrefHome } from "#lib/workspace/home.ts" const temporaryPaths: string[] = [] diff --git a/src/lib/sources/tarball/fetch.ts b/src/lib/sources/tarball/fetch.ts index 8c6ab50..f9d6668 100644 --- a/src/lib/sources/tarball/fetch.ts +++ b/src/lib/sources/tarball/fetch.ts @@ -4,7 +4,7 @@ import * as HttpClient from "effect/http/HttpClient" import * as HttpClientResponse from "effect/http/HttpClientResponse" import * as Path from "effect/Path" import { parseTarGzip, type ParsedTarFileItem } from "nanotar" -import type { PackageIdentity } from "#lib/core/packages.ts" +import type { PackageIdentity } from "#lib/core/identity.ts" import type { TarballSource } from "#lib/core/source.ts" import { TarballFetchError } from "#lib/core/errors.ts" import { checkIsPathWithin } from "#lib/shared/path.ts" diff --git a/src/lib/store/__tests__/store.test.ts b/src/lib/store/__tests__/store.test.ts index eced956..2d58a17 100644 --- a/src/lib/store/__tests__/store.test.ts +++ b/src/lib/store/__tests__/store.test.ts @@ -7,13 +7,13 @@ import * as NodeServices from "@effect/platform-node/NodeServices" import * as Effect from "effect/Effect" import * as Layer from "effect/Layer" import { afterEach, describe, expect, it } from "vitest" +import { PackrefHome } from "#lib/layout/home.ts" import { getStoreEntryPath, hasStoreEntry, listStoreEntries, removeStoreEntry, } from "#lib/store/index.ts" -import { PackrefHome } from "#lib/workspace/home.ts" const temporaryPaths: string[] = [] diff --git a/src/lib/store/index.ts b/src/lib/store/index.ts index 581ee49..cdf4297 100644 --- a/src/lib/store/index.ts +++ b/src/lib/store/index.ts @@ -8,19 +8,16 @@ import * as Schema from "effect/Schema" import * as Semaphore from "effect/Semaphore" import type { PackageSource } from "#lib/core/source.ts" import { GlobalStoreFilesystemError, StoreCorruptedError } from "#lib/core/errors.ts" -import { - packageIdentityOrder, - PACKAGE_DIRECTORY_NAME, - SUPPORTED_REPOSITORY_PROVIDERS, - type PackageIdentity, -} from "#lib/core/packages.ts" +import { type PackageIdentity, packageIdentityOrder } from "#lib/core/identity.ts" +import { PACKAGE_DIRECTORY_NAME } from "#lib/core/packages.ts" +import { checkIsRepositoryProvider } from "#lib/core/repository.ts" import { PackageSourceSchema } from "#lib/core/source.ts" +import { PackrefHome } from "#lib/layout/home.ts" import { formatJson } from "#lib/shared/json.ts" import { getGlobalStorePath, getStoreEntryPaths as getPathsForStoreEntry, } from "#lib/store/paths.ts" -import { PackrefHome } from "#lib/workspace/home.ts" export interface StoreEntry { readonly identity: PackageIdentity @@ -121,8 +118,7 @@ export const listStoreEntries = Effect.fn("listStoreEntries")(function* () { const packageSegmentPath = path.join(registryPath, packageSegment) const hasNestedPackageName = - packageSegment.startsWith("@") - || SUPPORTED_REPOSITORY_PROVIDERS.some((provider) => provider === registry) + packageSegment.startsWith("@") || checkIsRepositoryProvider(registry) if (hasNestedPackageName) { const scopedPackages = yield* listDirectoryOrEmpty(packageSegmentPath, semaphore) diff --git a/src/lib/store/paths.ts b/src/lib/store/paths.ts index 111b215..ee01043 100644 --- a/src/lib/store/paths.ts +++ b/src/lib/store/paths.ts @@ -1,7 +1,8 @@ import * as Effect from "effect/Effect" import * as Path from "effect/Path" -import { getPackageIdentitySegments, type PackageIdentity } from "#lib/core/packages.ts" -import { getGlobalDirectoryPath } from "#lib/workspace/paths.ts" +import type { PackageIdentity } from "#lib/core/identity.ts" +import { getPackageIdentitySegments } from "#lib/core/packages.ts" +import { getGlobalDirectoryPath } from "#lib/layout/paths.ts" export const STORE_DIRECTORY_NAME = "store" export const STORE_METADATA_DIRECTORY_NAME = ".metadata" @@ -9,16 +10,6 @@ export const STORE_METADATA_DIRECTORY_NAME = ".metadata" export const getGlobalStorePath = (path: Path.Path, home: string) => path.join(getGlobalDirectoryPath(path, home), STORE_DIRECTORY_NAME) -export const getStorePackagePath = Effect.fn("getStorePackagePath")(function* ( - storeRoot: string, - identity: PackageIdentity -) { - const path = yield* Path.Path - const segments = yield* getPackageIdentitySegments(identity) - - return path.join(storeRoot, ...segments) -}) - export const getStoreEntryPaths = Effect.fn("getStoreEntryPaths")(function* ( storeRoot: string, identity: PackageIdentity diff --git a/src/lib/workspace/config.ts b/src/lib/workspace/config.ts index 7603830..2454d79 100644 --- a/src/lib/workspace/config.ts +++ b/src/lib/workspace/config.ts @@ -4,9 +4,9 @@ import * as Filter from "effect/Filter" import * as Path from "effect/Path" import * as Schema from "effect/Schema" import { ConfigParseError } from "#lib/core/errors.ts" +import { PackrefHome } from "#lib/layout/home.ts" +import { getGlobalConfigPath, getGlobalDirectoryPath } from "#lib/layout/paths.ts" import { formatJson } from "#lib/shared/json.ts" -import { PackrefHome } from "#lib/workspace/home.ts" -import { getGlobalConfigPath, getGlobalDirectoryPath } from "#lib/workspace/paths.ts" export const GlobalConfigSchema = Schema.Struct({ projects: Schema.Array(Schema.String), diff --git a/src/lib/workspace/lockfile.ts b/src/lib/workspace/lockfile.ts index 68a4a0e..8cb44d4 100644 --- a/src/lib/workspace/lockfile.ts +++ b/src/lib/workspace/lockfile.ts @@ -4,16 +4,16 @@ import * as FileSystem from "effect/FileSystem" import * as Filter from "effect/Filter" import * as Path from "effect/Path" import * as Schema from "effect/Schema" +import type { ParsedPackageSpec } from "#lib/core/packages.ts" import { LockfileParseError } from "#lib/core/errors.ts" import { + type PackageIdentity, packageIdentityEquivalence, packageIdentityOrder, - type PackageIdentity, - type ParsedPackageSpec, -} from "#lib/core/packages.ts" +} from "#lib/core/identity.ts" import { PackageSourceSchema } from "#lib/core/source.ts" +import { getProjectLockfilePath } from "#lib/layout/paths.ts" import { formatJson } from "#lib/shared/json.ts" -import { getProjectLockfilePath } from "#lib/workspace/paths.ts" export const PackageEntrySchema = Schema.Struct({ name: Schema.String, diff --git a/src/lib/workspace/project.ts b/src/lib/workspace/project.ts index a3df2a7..643d9b5 100644 --- a/src/lib/workspace/project.ts +++ b/src/lib/workspace/project.ts @@ -1,12 +1,11 @@ import * as Effect from "effect/Effect" import * as FileSystem from "effect/FileSystem" import * as Path from "effect/Path" -import type { PackageIdentity } from "#lib/core/packages.ts" +import type { PackageIdentity } from "#lib/core/identity.ts" import type { PackageSource } from "#lib/core/source.ts" import { NotInitializedError, ReflinkError } from "#lib/core/errors.ts" +import { getDirectoryPath, getPackageIdentityPath } from "#lib/layout/paths.ts" import { checkIsPathWithin } from "#lib/shared/path.ts" -import { getStorePackagePath } from "#lib/store/paths.ts" -import { getDirectoryPath } from "#lib/workspace/paths.ts" import { Reflinker } from "#lib/workspace/reflinker.ts" export const ensureDirectory = Effect.fn("ensureDirectory")(function* (projectPath: string) { @@ -43,7 +42,7 @@ export const getProjectReferencePath = Effect.fn("getProjectReferencePath")(func ) { const path = yield* Path.Path - return yield* getStorePackagePath(getDirectoryPath(path, projectPath), identity) + return yield* getPackageIdentityPath(getDirectoryPath(path, projectPath), identity) }) export const hasProjectReference = Effect.fn("hasProjectReference")(function* ( @@ -66,7 +65,7 @@ export const createProjectReference = Effect.fn("createProjectReference")(functi const path = yield* Path.Path const reflinker = yield* Reflinker const projectDirectoryPath = getDirectoryPath(path, projectPath) - const targetPath = yield* getStorePackagePath(projectDirectoryPath, identity) + const targetPath = yield* getPackageIdentityPath(projectDirectoryPath, identity) const referenceSourcePath = source.type === "repository" && source.directory !== undefined ? path.resolve(storePath, source.directory) From 0db60842f9bc10ad74be9eccd1389132af6c7db8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adel=20Rodr=C3=ADguez?= Date: Thu, 1 Oct 2026 14:20:50 -0400 Subject: [PATCH 2/7] Check layer allow-list for cycles and reject relative lib imports Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/architecture.md | 2 +- src/lib/__tests__/layers.test.ts | 87 ++++++++++++++++++++------------ 2 files changed, 55 insertions(+), 34 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index c172119..53e38ba 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -26,7 +26,7 @@ executable `bin/packref` imports that output. | `sources` | Normalize, select, and fetch repository or tarball source, including source-specific external adapters. | | `store` | Address and manage immutable global source snapshots by package identity. | | `workspace` | Own the Packref lockfile, user-level state, integrations, and reference materialization adapters. | -| `layout` | Define the on-disk paths of project state, user-level state, and the global store, and the Packref home. | +| `layout` | Define the on-disk paths of project state and user-level state, and the Packref home. | | `core` | Define package identity, package specs, repository hosts, source types, registries, and shared typed errors. | ### Layers diff --git a/src/lib/__tests__/layers.test.ts b/src/lib/__tests__/layers.test.ts index e765099..11f2dc0 100644 --- a/src/lib/__tests__/layers.test.ts +++ b/src/lib/__tests__/layers.test.ts @@ -31,6 +31,7 @@ type LibFolder = keyof typeof ALLOWED_DEPENDENCIES const LIB_ROOT = join(import.meta.dirname, "..") const LIB_IMPORT_PATTERN = /from "#lib\/(?[^"]+)"/gu +const RELATIVE_IMPORT_PATTERN = /from "(?\.\.?\/[^"]*)"/gu const sourceFiles = readdirSync(LIB_ROOT, { recursive: true, withFileTypes: true }) .filter( @@ -42,17 +43,53 @@ const sourceFiles = readdirSync(LIB_ROOT, { recursive: true, withFileTypes: true .map((entry) => relative(LIB_ROOT, join(entry.parentPath, entry.name)).replaceAll("\\", "/")) .toSorted() +const sourceContents = new Map( + sourceFiles.map((file) => [file, readFileSync(join(LIB_ROOT, file), "utf8")]) +) + const importGraph = new Map( - sourceFiles.map((file) => [ + [...sourceContents].map(([file, contents]) => [ file, - [...readFileSync(join(LIB_ROOT, file), "utf8").matchAll(LIB_IMPORT_PATTERN)].map( - (match) => match.groups?.target ?? "" - ), + [...contents.matchAll(LIB_IMPORT_PATTERN)].map((match) => match.groups?.target ?? ""), ]) ) const getFolder = (file: string) => file.split("/")[0] ?? "" +const findCycles = (graph: ReadonlyMap) => { + const cycles: string[] = [] + const visiting: string[] = [] + const visited = new Set() + + const visit = (node: string) => { + const cycleStart = visiting.indexOf(node) + + if (cycleStart !== -1) { + cycles.push([...visiting.slice(cycleStart), node].join(" -> ")) + return + } + + if (visited.has(node)) { + return + } + + visiting.push(node) + + for (const target of graph.get(node) ?? []) { + visit(target) + } + + visiting.pop() + visited.add(node) + } + + for (const node of graph.keys()) { + visit(node) + } + + return cycles +} + const checkIsLibFolder = (folder: string): folder is LibFolder => Object.hasOwn(ALLOWED_DEPENDENCIES, folder) @@ -78,37 +115,21 @@ describe("lib layers", () => { expect(violations).toEqual([]) }) - it("has no import cycles between files", () => { - const cycles: string[] = [] - const visiting: string[] = [] - const visited = new Set() - - const visit = (file: string) => { - const cycleStart = visiting.indexOf(file) - - if (cycleStart !== -1) { - cycles.push([...visiting.slice(cycleStart), file].join(" -> ")) - return - } - - if (visited.has(file)) { - return - } - - visiting.push(file) - - for (const target of importGraph.get(file) ?? []) { - visit(target) - } + it("allows no cycles between folders", () => { + expect(findCycles(new Map(Object.entries(ALLOWED_DEPENDENCIES)))).toEqual([]) + }) - visiting.pop() - visited.add(file) - } + it("uses #lib specifiers instead of relative imports", () => { + const relativeImports = [...sourceContents].flatMap(([file, contents]) => + [...contents.matchAll(RELATIVE_IMPORT_PATTERN)].map( + (match) => `${file} -> ${match.groups?.target ?? ""}` + ) + ) - for (const file of sourceFiles) { - visit(file) - } + expect(relativeImports).toEqual([]) + }) - expect(cycles).toEqual([]) + it("has no import cycles between files", () => { + expect(findCycles(importGraph)).toEqual([]) }) }) From 3194e7214373354a6075296635a2bb14795b5fb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adel=20Rodr=C3=ADguez?= Date: Thu, 1 Oct 2026 14:29:09 -0400 Subject: [PATCH 3/7] Remove the lib layers test Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/architecture.md | 3 - src/lib/__tests__/layers.test.ts | 135 ------------------------------- 2 files changed, 138 deletions(-) delete mode 100644 src/lib/__tests__/layers.test.ts diff --git a/docs/architecture.md b/docs/architecture.md index 53e38ba..ee09bc0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -40,9 +40,6 @@ layers, and folders in the same layer do not import each other: 4. `sources` (imports `store` to write snapshots) 5. `references` -`src/lib/__tests__/layers.test.ts` enforces these rules and rejects import cycles between files. When -you add a folder or a dependency, update `ALLOWED_DEPENDENCIES` in that test. - Commands remain thin so the `references` interface is also the main behavior test seam. Registry and manifest adapters do not know filesystem layout, while source adapters do not know project manifests or lockfile mutation rules. diff --git a/src/lib/__tests__/layers.test.ts b/src/lib/__tests__/layers.test.ts deleted file mode 100644 index 11f2dc0..0000000 --- a/src/lib/__tests__/layers.test.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { readdirSync, readFileSync } from "node:fs" -import { join, relative } from "node:path" -import { describe, expect, it } from "vitest" - -/** - * Each lib folder lists the folders it may import. A folder never imports a folder at the same or a - * higher layer, so dependencies point in a single direction. - */ -const ALLOWED_DEPENDENCIES = { - core: [], - layout: ["core"], - manifests: ["core"], - references: [ - "core", - "layout", - "manifests", - "registries", - "shared", - "sources", - "store", - "workspace", - ], - registries: ["core"], - shared: [], - sources: ["core", "shared", "store"], - store: ["core", "layout", "shared"], - workspace: ["core", "layout", "shared"], -} satisfies Record - -type LibFolder = keyof typeof ALLOWED_DEPENDENCIES - -const LIB_ROOT = join(import.meta.dirname, "..") -const LIB_IMPORT_PATTERN = /from "#lib\/(?[^"]+)"/gu -const RELATIVE_IMPORT_PATTERN = /from "(?\.\.?\/[^"]*)"/gu - -const sourceFiles = readdirSync(LIB_ROOT, { recursive: true, withFileTypes: true }) - .filter( - (entry) => - entry.isFile() - && entry.name.endsWith(".ts") - && !entry.parentPath.split(/[\\/]/u).includes("__tests__") - ) - .map((entry) => relative(LIB_ROOT, join(entry.parentPath, entry.name)).replaceAll("\\", "/")) - .toSorted() - -const sourceContents = new Map( - sourceFiles.map((file) => [file, readFileSync(join(LIB_ROOT, file), "utf8")]) -) - -const importGraph = new Map( - [...sourceContents].map(([file, contents]) => [ - file, - [...contents.matchAll(LIB_IMPORT_PATTERN)].map((match) => match.groups?.target ?? ""), - ]) -) - -const getFolder = (file: string) => file.split("/")[0] ?? "" - -const findCycles = (graph: ReadonlyMap) => { - const cycles: string[] = [] - const visiting: string[] = [] - const visited = new Set() - - const visit = (node: string) => { - const cycleStart = visiting.indexOf(node) - - if (cycleStart !== -1) { - cycles.push([...visiting.slice(cycleStart), node].join(" -> ")) - return - } - - if (visited.has(node)) { - return - } - - visiting.push(node) - - for (const target of graph.get(node) ?? []) { - visit(target) - } - - visiting.pop() - visited.add(node) - } - - for (const node of graph.keys()) { - visit(node) - } - - return cycles -} - -const checkIsLibFolder = (folder: string): folder is LibFolder => - Object.hasOwn(ALLOWED_DEPENDENCIES, folder) - -describe("lib layers", () => { - it("assigns every lib folder to a layer", () => { - const folders = new Set(sourceFiles.map(getFolder)) - - expect([...folders].filter((folder) => !checkIsLibFolder(folder))).toEqual([]) - }) - - it("imports only from allowed lower layers", () => { - const violations = [...importGraph].flatMap(([file, targets]) => { - const folder = getFolder(file) - const allowed: readonly string[] = checkIsLibFolder(folder) - ? ALLOWED_DEPENDENCIES[folder] - : [] - - return targets - .filter((target) => getFolder(target) !== folder && !allowed.includes(getFolder(target))) - .map((target) => `${file} -> ${target}`) - }) - - expect(violations).toEqual([]) - }) - - it("allows no cycles between folders", () => { - expect(findCycles(new Map(Object.entries(ALLOWED_DEPENDENCIES)))).toEqual([]) - }) - - it("uses #lib specifiers instead of relative imports", () => { - const relativeImports = [...sourceContents].flatMap(([file, contents]) => - [...contents.matchAll(RELATIVE_IMPORT_PATTERN)].map( - (match) => `${file} -> ${match.groups?.target ?? ""}` - ) - ) - - expect(relativeImports).toEqual([]) - }) - - it("has no import cycles between files", () => { - expect(findCycles(importGraph)).toEqual([]) - }) -}) From a6b7ccad41e6c3aa182407a8ade6bff0a521f726 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adel=20Rodr=C3=ADguez?= Date: Thu, 1 Oct 2026 14:30:30 -0400 Subject: [PATCH 4/7] Report import cycles through type-only imports Co-Authored-By: Claude Opus 5.5 (1M context) --- oxlint.config.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/oxlint.config.ts b/oxlint.config.ts index b8dda9d..aedadb0 100644 --- a/oxlint.config.ts +++ b/oxlint.config.ts @@ -11,6 +11,8 @@ export default defineConfig({ typeCheck: true, }, rules: { + // Also report cycles that pass through type-only imports. + "import/no-cycle": ["error", { ignoreTypes: false }], // Effect combinators such as `Option.some(value)`, `Option.flatMap(fn)`, and // `Effect.map(effect, fn)` are indistinguishable from array iteration methods // to these array-specific rules. From 38f8f460d9a06f7cb17f977798c96fd837198029 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adel=20Rodr=C3=ADguez?= Date: Thu, 1 Oct 2026 14:34:03 -0400 Subject: [PATCH 5/7] Enforce lib layers with no-restricted-imports Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/architecture.md | 4 +++ oxlint.config.ts | 62 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/docs/architecture.md b/docs/architecture.md index ee09bc0..4e5d306 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -40,6 +40,10 @@ layers, and folders in the same layer do not import each other: 4. `sources` (imports `store` to write snapshots) 5. `references` +`LIB_LAYERS` in `oxlint.config.ts` enforces these layers with `no-restricted-imports`, and +`import/no-cycle` rejects import cycles between files. When you add a folder or a dependency, update +`LIB_LAYERS`. + Commands remain thin so the `references` interface is also the main behavior test seam. Registry and manifest adapters do not know filesystem layout, while source adapters do not know project manifests or lockfile mutation rules. diff --git a/oxlint.config.ts b/oxlint.config.ts index aedadb0..a65ef45 100644 --- a/oxlint.config.ts +++ b/oxlint.config.ts @@ -1,6 +1,56 @@ import core, { ignorePatterns } from "adamantite/lint" import antislop from "adamantite/lint/antislop" -import { defineConfig } from "oxlint" +import { defineConfig, type OxlintOverride } from "oxlint" + +// Each `src/lib` folder lists the folders it may import, so dependencies point in a single +// direction. See the layers in `docs/architecture.md`. +const LIB_LAYERS = { + core: [], + layout: ["core"], + manifests: ["core"], + references: [ + "core", + "layout", + "manifests", + "registries", + "shared", + "sources", + "store", + "workspace", + ], + registries: ["core"], + shared: [], + sources: ["core", "shared", "store"], + store: ["core", "layout", "shared"], + workspace: ["core", "layout", "shared"], +} satisfies Record + +const libFolders = Object.keys(LIB_LAYERS) + +const libLayerOverrides = Object.entries(LIB_LAYERS).map( + ([folder, allowed]: [string, readonly string[]]): OxlintOverride => ({ + files: [`src/lib/${folder}/**/*.ts`], + rules: { + "no-restricted-imports": [ + "error", + { + patterns: [ + { + group: libFolders + .filter((target) => target !== folder && !allowed.includes(target)) + .map((target) => `#lib/${target}/**`), + message: `${folder} may import only ${[folder, ...allowed].join(", ")}.`, + }, + { + group: ["./**", "../**"], + message: "Use a #lib import so the layer rules apply.", + }, + ], + }, + ], + }, + }) +) export default defineConfig({ extends: [core, antislop], @@ -10,6 +60,16 @@ export default defineConfig({ typeAware: true, typeCheck: true, }, + overrides: [ + ...libLayerOverrides, + { + // Tests compose layers from several folders to build their fixtures. + files: ["src/lib/**/__tests__/**/*.ts"], + rules: { + "no-restricted-imports": "off", + }, + }, + ], rules: { // Also report cycles that pass through type-only imports. "import/no-cycle": ["error", { ignoreTypes: false }], From 1010499979518e27532fb451821508e4ee0a6604 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adel=20Rodr=C3=ADguez?= Date: Thu, 1 Oct 2026 14:40:41 -0400 Subject: [PATCH 6/7] Write lib layer lint overrides explicitly Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/architecture.md | 5 +- oxlint.config.ts | 250 ++++++++++++++++++++++++++++++++++--------- 2 files changed, 201 insertions(+), 54 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 4e5d306..d78a9ba 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -40,9 +40,10 @@ layers, and folders in the same layer do not import each other: 4. `sources` (imports `store` to write snapshots) 5. `references` -`LIB_LAYERS` in `oxlint.config.ts` enforces these layers with `no-restricted-imports`, and +`oxlint.config.ts` enforces these layers with one `no-restricted-imports` override for each folder. +`import/no-relative-parent-imports` stops relative imports from bypassing these overrides, and `import/no-cycle` rejects import cycles between files. When you add a folder or a dependency, update -`LIB_LAYERS`. +the overrides. Commands remain thin so the `references` interface is also the main behavior test seam. Registry and manifest adapters do not know filesystem layout, while source adapters do not know project manifests diff --git a/oxlint.config.ts b/oxlint.config.ts index a65ef45..71a5cc7 100644 --- a/oxlint.config.ts +++ b/oxlint.config.ts @@ -1,56 +1,6 @@ import core, { ignorePatterns } from "adamantite/lint" import antislop from "adamantite/lint/antislop" -import { defineConfig, type OxlintOverride } from "oxlint" - -// Each `src/lib` folder lists the folders it may import, so dependencies point in a single -// direction. See the layers in `docs/architecture.md`. -const LIB_LAYERS = { - core: [], - layout: ["core"], - manifests: ["core"], - references: [ - "core", - "layout", - "manifests", - "registries", - "shared", - "sources", - "store", - "workspace", - ], - registries: ["core"], - shared: [], - sources: ["core", "shared", "store"], - store: ["core", "layout", "shared"], - workspace: ["core", "layout", "shared"], -} satisfies Record - -const libFolders = Object.keys(LIB_LAYERS) - -const libLayerOverrides = Object.entries(LIB_LAYERS).map( - ([folder, allowed]: [string, readonly string[]]): OxlintOverride => ({ - files: [`src/lib/${folder}/**/*.ts`], - rules: { - "no-restricted-imports": [ - "error", - { - patterns: [ - { - group: libFolders - .filter((target) => target !== folder && !allowed.includes(target)) - .map((target) => `#lib/${target}/**`), - message: `${folder} may import only ${[folder, ...allowed].join(", ")}.`, - }, - { - group: ["./**", "../**"], - message: "Use a #lib import so the layer rules apply.", - }, - ], - }, - ], - }, - }) -) +import { defineConfig } from "oxlint" export default defineConfig({ extends: [core, antislop], @@ -61,11 +11,207 @@ export default defineConfig({ typeCheck: true, }, overrides: [ - ...libLayerOverrides, + // Each `src/lib` folder imports only the folders in lower layers. See the layers in + // `docs/architecture.md`. `references` is the top layer and may import every other folder. + { + files: ["src/lib/**/*.ts"], + rules: { + "import/no-relative-parent-imports": "error", + }, + }, + { + files: ["src/lib/core/**/*.ts"], + rules: { + "no-restricted-imports": [ + "error", + { + patterns: [ + { + group: [ + "#lib/layout/**", + "#lib/manifests/**", + "#lib/references/**", + "#lib/registries/**", + "#lib/shared/**", + "#lib/sources/**", + "#lib/store/**", + "#lib/workspace/**", + ], + message: "core may import only no other lib folder.", + }, + ], + }, + ], + }, + }, + { + files: ["src/lib/shared/**/*.ts"], + rules: { + "no-restricted-imports": [ + "error", + { + patterns: [ + { + group: [ + "#lib/core/**", + "#lib/layout/**", + "#lib/manifests/**", + "#lib/references/**", + "#lib/registries/**", + "#lib/sources/**", + "#lib/store/**", + "#lib/workspace/**", + ], + message: "shared may import only no other lib folder.", + }, + ], + }, + ], + }, + }, + { + files: ["src/lib/layout/**/*.ts"], + rules: { + "no-restricted-imports": [ + "error", + { + patterns: [ + { + group: [ + "#lib/manifests/**", + "#lib/references/**", + "#lib/registries/**", + "#lib/shared/**", + "#lib/sources/**", + "#lib/store/**", + "#lib/workspace/**", + ], + message: "layout may import only core.", + }, + ], + }, + ], + }, + }, + { + files: ["src/lib/manifests/**/*.ts"], + rules: { + "no-restricted-imports": [ + "error", + { + patterns: [ + { + group: [ + "#lib/layout/**", + "#lib/references/**", + "#lib/registries/**", + "#lib/shared/**", + "#lib/sources/**", + "#lib/store/**", + "#lib/workspace/**", + ], + message: "manifests may import only core.", + }, + ], + }, + ], + }, + }, + { + files: ["src/lib/registries/**/*.ts"], + rules: { + "no-restricted-imports": [ + "error", + { + patterns: [ + { + group: [ + "#lib/layout/**", + "#lib/manifests/**", + "#lib/references/**", + "#lib/shared/**", + "#lib/sources/**", + "#lib/store/**", + "#lib/workspace/**", + ], + message: "registries may import only core.", + }, + ], + }, + ], + }, + }, + { + files: ["src/lib/store/**/*.ts"], + rules: { + "no-restricted-imports": [ + "error", + { + patterns: [ + { + group: [ + "#lib/manifests/**", + "#lib/references/**", + "#lib/registries/**", + "#lib/sources/**", + "#lib/workspace/**", + ], + message: "store may import only core, layout, shared.", + }, + ], + }, + ], + }, + }, + { + files: ["src/lib/workspace/**/*.ts"], + rules: { + "no-restricted-imports": [ + "error", + { + patterns: [ + { + group: [ + "#lib/manifests/**", + "#lib/references/**", + "#lib/registries/**", + "#lib/sources/**", + "#lib/store/**", + ], + message: "workspace may import only core, layout, shared.", + }, + ], + }, + ], + }, + }, + { + files: ["src/lib/sources/**/*.ts"], + rules: { + "no-restricted-imports": [ + "error", + { + patterns: [ + { + group: [ + "#lib/layout/**", + "#lib/manifests/**", + "#lib/references/**", + "#lib/registries/**", + "#lib/workspace/**", + ], + message: "sources may import only core, shared, store.", + }, + ], + }, + ], + }, + }, { // Tests compose layers from several folders to build their fixtures. files: ["src/lib/**/__tests__/**/*.ts"], rules: { + "import/no-relative-parent-imports": "off", "no-restricted-imports": "off", }, }, From 8ed9423e6fe9834f868997fc68fe1b26ef11fac2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adel=20Rodr=C3=ADguez?= Date: Thu, 1 Oct 2026 16:48:39 -0400 Subject: [PATCH 7/7] Fix layer lint messages for core and shared Co-Authored-By: Claude Opus 5.5 (1M context) --- oxlint.config.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/oxlint.config.ts b/oxlint.config.ts index 71a5cc7..11104a1 100644 --- a/oxlint.config.ts +++ b/oxlint.config.ts @@ -37,7 +37,7 @@ export default defineConfig({ "#lib/store/**", "#lib/workspace/**", ], - message: "core may import only no other lib folder.", + message: "core must not import other lib folders.", }, ], }, @@ -62,7 +62,7 @@ export default defineConfig({ "#lib/store/**", "#lib/workspace/**", ], - message: "shared may import only no other lib folder.", + message: "shared must not import other lib folders.", }, ], },