diff --git a/.changeset/architecture-catalog-group-overrides.md b/.changeset/architecture-catalog-group-overrides.md new file mode 100644 index 000000000..a42cb562b --- /dev/null +++ b/.changeset/architecture-catalog-group-overrides.md @@ -0,0 +1,5 @@ +--- +"@croco/architecture-policy": patch +--- + +Expose package catalog group override metadata through the parsed architecture manifest contract. diff --git a/croco.arch.json b/croco.arch.json index b427899d1..598640ee2 100644 --- a/croco.arch.json +++ b/croco.arch.json @@ -18,23 +18,58 @@ "framework": { "description": "Build-time and runtime framework primitives without provider or transport implementations.", "packages": [ - "@croco/*-core", - "@croco/architecture-policy", + "@croco/access-core", + "@croco/admin-core", + "@croco/admin-ops", + "@croco/analytics-core", + "@croco/audit-core", + "@croco/auth-core", + "@croco/batch-core", + "@croco/billing-core", "@croco/cache-core", + "@croco/customer-health-core", "@croco/dataloader-core", "@croco/diagnostics-core", + "@croco/entitlements-core", "@croco/events-core", "@croco/events-inmemory", - "@croco/framework-*", + "@croco/execution-core", + "@croco/features-core", + "@croco/framework-config", + "@croco/framework-context", + "@croco/framework-logger", + "@croco/framework-module", + "@croco/framework-preset", + "@croco/framework-routes", "@croco/gid-core", + "@croco/governance-core", "@croco/health-core", "@croco/idempotency-core", + "@croco/impersonation-core", + "@croco/invitation-core", + "@croco/lifecycle-core", + "@croco/llm-core", + "@croco/llm-metering", + "@croco/membership-core", + "@croco/metering-core", + "@croco/metrics-core", + "@croco/notifications-core", + "@croco/onboarding-core", + "@croco/outbox-core", "@croco/pagination-core", "@croco/problems-core", + "@croco/ratelimit-core", "@croco/repository-core", "@croco/retry-core", + "@croco/search-core", + "@croco/storage-core", + "@croco/tasks-core", "@croco/tenant-core", - "@croco/tx-core" + "@croco/telemetry-api", + "@croco/triggers-core", + "@croco/tx-core", + "@croco/webhooks-core", + "@croco/workflow-core" ] }, "protocols": { @@ -84,6 +119,7 @@ "app": { "description": "Repository-owned CLIs, scaffolds, docs, and release tooling.", "packages": [ + "@croco/architecture-policy", "@croco/cli", "@croco/docs", "@croco/esbuild-plugin", @@ -95,6 +131,26 @@ ] } }, + "packageCatalogGroupOverrides": [ + { + "package": "@croco/events-tx", + "catalogGroup": "Core", + "policyGroup": "integrations", + "reason": "events-tx binds event contracts to transaction infrastructure, so architecture policy treats it as an adapter boundary." + }, + { + "package": "@croco/telemetry-api", + "catalogGroup": "Integration", + "policyGroup": "framework", + "reason": "telemetry-api is the application-facing telemetry contract consumed by framework/core packages; telemetry-sdk-node remains the runtime integration." + }, + { + "package": "@croco/tx-drizzle", + "catalogGroup": "Core", + "policyGroup": "integrations", + "reason": "tx-drizzle is the Drizzle transaction implementation even though the package catalog keeps transaction packages in Core." + } + ], "rules": { "forbiddenImports": [ { diff --git a/packages/architecture-policy/src/index.ts b/packages/architecture-policy/src/index.ts index 7fe7bdf4d..a4f320e5e 100644 --- a/packages/architecture-policy/src/index.ts +++ b/packages/architecture-policy/src/index.ts @@ -116,6 +116,7 @@ export type ArchitecturePolicyManifest = { readonly include?: readonly string[]; readonly ignore?: readonly string[]; readonly packageGroups?: Readonly>; + readonly packageCatalogGroupOverrides?: unknown; readonly rules?: { readonly forbiddenImports?: readonly ArchitectureForbiddenImportRule[]; readonly allowedGroupImports?: readonly ArchitectureAllowedGroupImportRule[]; @@ -225,6 +226,7 @@ export function parseArchitecturePolicyManifest(content: string): ArchitecturePo include: readStringArray(parsed.include), ignore: readStringArray(parsed.ignore), packageGroups: readPackageGroups(parsed.packageGroups), + packageCatalogGroupOverrides: parsed.packageCatalogGroupOverrides, rules: readRules(parsed.rules), }; } diff --git a/packages/docs/src/content/docs/api/architecture-policy/src/type-aliases/ArchitecturePolicyManifest.md b/packages/docs/src/content/docs/api/architecture-policy/src/type-aliases/ArchitecturePolicyManifest.md index 18c155a50..2c203f80f 100644 --- a/packages/docs/src/content/docs/api/architecture-policy/src/type-aliases/ArchitecturePolicyManifest.md +++ b/packages/docs/src/content/docs/api/architecture-policy/src/type-aliases/ArchitecturePolicyManifest.md @@ -13,31 +13,37 @@ title: "ArchitecturePolicyManifest" > `readonly` `optional` **ignore?**: readonly `string`[] -*** +--- ### include? > `readonly` `optional` **include?**: readonly `string`[] -*** +--- + +### packageCatalogGroupOverrides? + +> `readonly` `optional` **packageCatalogGroupOverrides?**: `unknown` + +--- ### packageGroups? > `readonly` `optional` **packageGroups?**: `Readonly`\<`Record`\<`string`, [`ArchitecturePolicyPackageGroup`](/api/architecture-policy/src/type-aliases/architecturepolicypackagegroup/)\>\> -*** +--- ### packageRoots? > `readonly` `optional` **packageRoots?**: readonly `string`[] -*** +--- ### policyName? > `readonly` `optional` **policyName?**: `string` -*** +--- ### rules? @@ -55,8 +61,8 @@ title: "ArchitecturePolicyManifest" > `readonly` `optional` **publicEntrypoints?**: [`ArchitecturePublicEntrypointRule`](/api/architecture-policy/src/type-aliases/architecturepublicentrypointrule/) -*** +--- ### schemaVersion -> `readonly` **schemaVersion**: *typeof* [`ARCHITECTURE_POLICY_SCHEMA_VERSION`](/api/architecture-policy/src/variables/architecture_policy_schema_version/) +> `readonly` **schemaVersion**: _typeof_ [`ARCHITECTURE_POLICY_SCHEMA_VERSION`](/api/architecture-policy/src/variables/architecture_policy_schema_version/) diff --git a/scripts/architecture-policy-check.mts b/scripts/architecture-policy-check.mts index 932670f53..81bce6587 100644 --- a/scripts/architecture-policy-check.mts +++ b/scripts/architecture-policy-check.mts @@ -1,10 +1,13 @@ #!/usr/bin/env node +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { dirname, join, relative, resolve } from "node:path"; import { checkArchitecturePolicy, formatArchitecturePolicyDiagnostic, readArchitecturePolicyManifest, } from "../packages/architecture-policy/src/index.ts"; +import type { ArchitecturePolicyManifest } from "../packages/architecture-policy/src/index.ts"; type Options = { readonly manifest: string; @@ -12,6 +15,53 @@ type Options = { readonly json: boolean; }; +type PackageJson = { + readonly name?: unknown; + readonly private?: unknown; +}; + +type WorkspacePackage = { + readonly name: string; + readonly relativeDir: string; + readonly shortName: string; +}; + +type PackageCatalogGroupOverride = { + readonly catalogGroup: string; + readonly packageName: string; + readonly policyGroup: string; + readonly reason: string; +}; + +type PackageCatalogGroupViolation = { + readonly message: string; + readonly recovery: string; + readonly evidence: string; +}; + +type PackageCatalogGroupConsistencyReport = { + readonly status: "pass" | "fail"; + readonly packageCount: number; + readonly violationCount: number; + readonly violations: readonly PackageCatalogGroupViolation[]; +}; + +type CatalogMetadata = { + readonly groups?: unknown; +}; + +const packageCatalogPath = join("docs", "package-catalog.json"); +const packageCatalogGroupToPolicyGroup = new Map([ + ["Core", "framework"], + ["Domain", "framework"], + ["Provider", "integrations"], + ["Integration", "integrations"], + ["Protocol", "protocols"], + ["Transport", "transports"], + ["Presentation", "presentation"], + ["Tooling", "app"], +]); + function parseArgs(args: readonly string[]): Options { let manifest = "croco.arch.json"; let rootDir = process.cwd(); @@ -60,14 +110,32 @@ function readFlagValue(args: readonly string[], index: number, flag: string): st function main(): void { const options = parseArgs(process.argv.slice(2)); - const manifest = readArchitecturePolicyManifest(options.manifest); + const manifestPath = resolveManifestPath(options.rootDir, options.manifest); + const manifest = readArchitecturePolicyManifest(manifestPath); const report = checkArchitecturePolicy({ rootDir: options.rootDir, manifest, }); + const packageCatalogGroupConsistency = checkPackageCatalogGroupConsistency({ + rootDir: options.rootDir, + manifest, + manifestPath, + }); + const status = + report.status === "fail" || packageCatalogGroupConsistency.status === "fail" ? "fail" : "pass"; if (options.json) { - console.log(JSON.stringify(report, null, 2)); + console.log( + JSON.stringify( + { + ...report, + status, + packageCatalogGroupConsistency, + }, + null, + 2, + ), + ); } else { for (const diagnostic of report.diagnostics) { console.error(formatArchitecturePolicyDiagnostic(diagnostic)); @@ -76,16 +144,440 @@ function main(): void { } console.error(` evidence: ${diagnostic.excerpt}`); } + + for (const violation of packageCatalogGroupConsistency.violations) { + console.error(formatPackageCatalogGroupViolation(violation)); + console.error(` action: ${violation.recovery}`); + console.error(` evidence: ${violation.evidence}`); + } } - if (report.status === "fail") { - console.error(`architecture-policy: ${report.diagnostics.length} diagnostic(s)`); + if (status === "fail") { + const diagnosticCount = + report.diagnostics.length + packageCatalogGroupConsistency.violationCount; + console.error(`architecture-policy: ${diagnosticCount} diagnostic(s)`); process.exit(1); } console.log( `architecture-policy: passed for ${report.importCount} import(s) across ${report.packageCount} package(s)`, ); + console.log( + `architecture-policy: package catalog group consistency passed for ${packageCatalogGroupConsistency.packageCount} public package(s)`, + ); +} + +function resolveManifestPath(rootDir: string, manifestPath: string): string { + return resolve(rootDir, manifestPath); +} + +function checkPackageCatalogGroupConsistency(options: { + readonly rootDir: string; + readonly manifest: ArchitecturePolicyManifest; + readonly manifestPath: string; +}): PackageCatalogGroupConsistencyReport { + const rootDir = resolve(options.rootDir); + const violations: PackageCatalogGroupViolation[] = []; + const packages = readPublicWorkspacePackages( + rootDir, + options.manifest.packageRoots ?? ["packages"], + ); + const packageByName = new Map(packages.map((pkg) => [pkg.name, pkg])); + const packageByShortName = new Map(packages.map((pkg) => [pkg.shortName, pkg])); + const catalogGroups = readPackageCatalogGroups(rootDir, packageByShortName, violations); + const overrides = readPackageCatalogGroupOverrides( + options.manifest.packageCatalogGroupOverrides, + packageByName, + violations, + ); + const overridesByPackage = new Map(overrides.map((override) => [override.packageName, override])); + + for (const pkg of packages) { + const catalogGroupMatches = catalogGroups.get(pkg.shortName) ?? []; + const architectureGroupMatches = findMatchingArchitecturePackageGroups(options.manifest, pkg); + + if (catalogGroupMatches.length === 0) { + violations.push({ + message: `public package ${pkg.shortName} is missing package catalog group metadata`, + recovery: `Add ${pkg.shortName} to exactly one docs/package-catalog.json groups.*.packages array.`, + evidence: packageCatalogPath, + }); + continue; + } + + if (catalogGroupMatches.length > 1) { + violations.push({ + message: `package ${pkg.shortName} appears in multiple package catalog groups (${catalogGroupMatches.join(", ")})`, + recovery: `Keep ${pkg.shortName} in one catalog group before comparing architecture policy group membership.`, + evidence: packageCatalogPath, + }); + continue; + } + + if (architectureGroupMatches.length === 0) { + violations.push({ + message: `public package ${pkg.name} is not classified by croco.arch.json packageGroups`, + recovery: `Add ${pkg.name} to exactly one croco.arch.json packageGroups entry or add a matching package pattern.`, + evidence: relative(rootDir, options.manifestPath), + }); + continue; + } + + if (architectureGroupMatches.length > 1) { + violations.push({ + message: `public package ${pkg.name} matches multiple croco.arch.json packageGroups (${architectureGroupMatches.join(", ")})`, + recovery: `Make ${pkg.name} match exactly one architecture policy package group.`, + evidence: relative(rootDir, options.manifestPath), + }); + continue; + } + + const catalogGroup = catalogGroupMatches[0]; + const policyGroup = architectureGroupMatches[0]; + const expectedPolicyGroup = packageCatalogGroupToPolicyGroup.get(catalogGroup); + if (!expectedPolicyGroup) { + violations.push({ + message: `package ${pkg.shortName} uses unsupported package catalog group ${catalogGroup}`, + recovery: `Add an architecture-policy mapping for catalog group ${catalogGroup} before using it.`, + evidence: packageCatalogPath, + }); + continue; + } + + const override = overridesByPackage.get(pkg.name); + const overrideMatchesActual = + override?.catalogGroup === catalogGroup && override.policyGroup === policyGroup; + const groupMismatch = policyGroup !== expectedPolicyGroup; + + if (override && !overrideMatchesActual) { + violations.push({ + message: `packageCatalogGroupOverrides entry for ${pkg.name} expects catalog=${override.catalogGroup} policy=${override.policyGroup} but actual catalog=${catalogGroup} policy=${policyGroup}`, + recovery: `Update or remove the stale override for ${pkg.name}.`, + evidence: relative(rootDir, options.manifestPath), + }); + continue; + } + + if (override && !groupMismatch) { + violations.push({ + message: `packageCatalogGroupOverrides entry for ${pkg.name} is no longer needed`, + recovery: `Remove the override now that ${pkg.name} has matching catalog and architecture policy groups.`, + evidence: relative(rootDir, options.manifestPath), + }); + continue; + } + + if (groupMismatch && !overrideMatchesActual) { + violations.push({ + message: `package ${pkg.name} catalog group ${catalogGroup} maps to policy group ${expectedPolicyGroup} but croco.arch.json assigns ${policyGroup}`, + recovery: `Move ${pkg.name} to packageGroups.${expectedPolicyGroup} or add a packageCatalogGroupOverrides entry with package, catalogGroup, policyGroup, and reason.`, + evidence: relative(rootDir, options.manifestPath), + }); + continue; + } + } + + return { + status: violations.length > 0 ? "fail" : "pass", + packageCount: packages.length, + violationCount: violations.length, + violations, + }; +} + +function readPublicWorkspacePackages( + rootDir: string, + packageRoots: readonly string[], +): readonly WorkspacePackage[] { + const packages: WorkspacePackage[] = []; + + for (const packageJsonPath of packageRoots.flatMap((packageRoot) => + findPackageJsonFiles(join(rootDir, packageRoot)), + )) { + const packageJson = readJsonFile(packageJsonPath); + if (typeof packageJson.name !== "string" || packageJson.name.length === 0) { + throw new Error(`${relative(rootDir, packageJsonPath)} is missing a string name`); + } + + if (packageJson.private === true) { + continue; + } + + packages.push({ + name: packageJson.name, + relativeDir: toPosixPath(relative(rootDir, dirname(packageJsonPath))), + shortName: toShortPackageName(packageJson.name), + }); + } + + return packages.sort((left, right) => { + const byShortName = left.shortName.localeCompare(right.shortName); + return byShortName === 0 ? left.relativeDir.localeCompare(right.relativeDir) : byShortName; + }); +} + +function readPackageCatalogGroups( + rootDir: string, + packageByShortName: ReadonlyMap, + violations: PackageCatalogGroupViolation[], +): ReadonlyMap { + const catalogPath = join(rootDir, packageCatalogPath); + if (!existsSync(catalogPath)) { + violations.push({ + message: "docs/package-catalog.json is missing", + recovery: "Restore docs/package-catalog.json before running architecture policy checks.", + evidence: packageCatalogPath, + }); + return new Map(); + } + + const metadata = readJsonFile(catalogPath); + if (!isRecord(metadata.groups)) { + violations.push({ + message: "docs/package-catalog.json groups must be an object", + recovery: "Restore package catalog group metadata before running architecture policy checks.", + evidence: packageCatalogPath, + }); + return new Map(); + } + + const assignments = new Map(); + for (const [groupName, groupConfig] of Object.entries(metadata.groups)) { + if (!isRecord(groupConfig)) { + violations.push({ + message: `docs/package-catalog.json groups.${groupName} must be an object`, + recovery: `Make groups.${groupName} contain a packages string array.`, + evidence: packageCatalogPath, + }); + continue; + } + + const packageNames = readStringArray(groupConfig.packages); + if (!packageNames) { + violations.push({ + message: `docs/package-catalog.json groups.${groupName}.packages must be a string array`, + recovery: `Set groups.${groupName}.packages to the package short names assigned to ${groupName}.`, + evidence: packageCatalogPath, + }); + continue; + } + + for (const packageName of packageNames) { + if (!packageByShortName.has(packageName)) { + violations.push({ + message: `package catalog group ${groupName} references missing public package ${packageName}`, + recovery: `Remove ${packageName} from docs/package-catalog.json or restore packages/${packageName}.`, + evidence: packageCatalogPath, + }); + continue; + } + + const current = assignments.get(packageName) ?? []; + current.push(groupName); + assignments.set(packageName, current); + } + } + + return assignments; +} + +function readPackageCatalogGroupOverrides( + value: unknown, + packageByName: ReadonlyMap, + violations: PackageCatalogGroupViolation[], +): PackageCatalogGroupOverride[] { + if (value === undefined) { + return []; + } + + if (!Array.isArray(value)) { + violations.push({ + message: "croco.arch.json packageCatalogGroupOverrides must be an array", + recovery: + "Set packageCatalogGroupOverrides to an array of explicit package override objects.", + evidence: "croco.arch.json", + }); + return []; + } + + const overrides: PackageCatalogGroupOverride[] = []; + const seenPackages = new Set(); + for (const [index, entry] of value.entries()) { + const location = `croco.arch.json packageCatalogGroupOverrides[${index}]`; + if (!isRecord(entry)) { + violations.push({ + message: `${location} must be an object`, + recovery: "Each override must include package, catalogGroup, policyGroup, and reason.", + evidence: "croco.arch.json", + }); + continue; + } + + const packageName = readNonEmptyString(entry.package); + const catalogGroup = readNonEmptyString(entry.catalogGroup); + const policyGroup = readNonEmptyString(entry.policyGroup); + const reason = readNonEmptyString(entry.reason); + if (!packageName || !catalogGroup || !policyGroup || !reason) { + violations.push({ + message: `${location} must include non-empty package, catalogGroup, policyGroup, and reason fields`, + recovery: + "Make the override explicit enough to review why catalog and policy group membership differ.", + evidence: "croco.arch.json", + }); + continue; + } + + if (!packageByName.has(packageName)) { + violations.push({ + message: `${location} references missing public package ${packageName}`, + recovery: `Remove the override or restore the public workspace package named ${packageName}.`, + evidence: "croco.arch.json", + }); + continue; + } + + if (seenPackages.has(packageName)) { + violations.push({ + message: `${location} duplicates an override for ${packageName}`, + recovery: `Keep one packageCatalogGroupOverrides entry for ${packageName}.`, + evidence: "croco.arch.json", + }); + continue; + } + + seenPackages.add(packageName); + overrides.push({ + packageName, + catalogGroup, + policyGroup, + reason, + }); + } + + return overrides; +} + +function findMatchingArchitecturePackageGroups( + manifest: ArchitecturePolicyManifest, + pkg: WorkspacePackage, +): readonly string[] { + const matches = new Set(); + const groups = Object.entries(manifest.packageGroups ?? {}).sort(([left], [right]) => + left.localeCompare(right), + ); + + for (const [groupName, group] of groups) { + if (matchesAnyPattern(pkg.name, group.packages ?? [])) { + matches.add(groupName); + } + + if (matchesAnyPattern(pkg.relativeDir, group.paths ?? [])) { + matches.add(groupName); + } + } + + return [...matches].sort((left, right) => left.localeCompare(right)); +} + +function formatPackageCatalogGroupViolation(violation: PackageCatalogGroupViolation): string { + return `ERROR architecture-policy/package-catalog-group: ${violation.message}`; +} + +function readJsonFile(path: string): T { + return JSON.parse(readFileSync(path, "utf-8")) as T; +} + +function readStringArray(value: unknown): readonly string[] | null { + if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string")) { + return null; + } + + return value; +} + +function readNonEmptyString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +function toShortPackageName(packageName: string): string { + return packageName.startsWith("@croco/") ? packageName.slice("@croco/".length) : packageName; +} + +function findPackageJsonFiles(dir: string, results: string[] = []): string[] { + if (!existsSync(dir)) { + return results; + } + + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const fullPath = join(dir, entry.name); + + if (entry.isDirectory()) { + if (shouldSkipDirectory(entry.name)) { + continue; + } + findPackageJsonFiles(fullPath, results); + continue; + } + + if (entry.isFile() && entry.name === "package.json") { + results.push(fullPath); + } + } + + return results.sort(); +} + +function shouldSkipDirectory(name: string): boolean { + return name === "node_modules" || name === "dist" || name === "build" || name === ".turbo"; +} + +function toPosixPath(value: string): string { + return value.split("\\").join("/"); +} + +function matchesAnyPattern(value: string, patterns: readonly string[]): boolean { + return patterns.some((pattern) => matchesPattern(value, pattern)); +} + +function matchesPattern(value: string, pattern: string): boolean { + return patternToRegExp(pattern).test(value); +} + +function patternToRegExp(pattern: string): RegExp { + let source = "^"; + + for (let index = 0; index < pattern.length; index += 1) { + const char = pattern[index]; + const next = pattern[index + 1]; + + if (char === "*" && next === "*") { + if (pattern[index + 2] === "/") { + source += "(?:.*/)?"; + index += 2; + continue; + } + source += ".*"; + index += 1; + continue; + } + + if (char === "*") { + source += "[^/]*"; + continue; + } + + source += escapeRegExp(char); + } + + return new RegExp(`${source}$`); +} + +function escapeRegExp(value: string): string { + return value.replace(/[\\^$+?.()|[\]{}]/g, "\\$&"); } try { diff --git a/scripts/tests/architecture-policy-check.spec.ts b/scripts/tests/architecture-policy-check.spec.ts new file mode 100644 index 000000000..8438eb424 --- /dev/null +++ b/scripts/tests/architecture-policy-check.spec.ts @@ -0,0 +1,325 @@ +import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; + +const scriptPath = resolve(__dirname, "../architecture-policy-check.mts"); +const scriptTestTimeout = 30_000; +const tempRoots: string[] = []; + +vi.setConfig({ testTimeout: scriptTestTimeout }); + +type ScriptResult = { + readonly output: string; + readonly status: number | null; +}; + +type ArchitecturePackageGroup = { + readonly packages?: readonly string[]; + readonly paths?: readonly string[]; +}; + +type PackageCatalogGroupOverride = { + readonly package: string; + readonly catalogGroup: string; + readonly policyGroup: string; + readonly reason: string; +}; + +describe("architecture-policy-check.mts", () => { + afterAll(() => { + vi.resetConfig(); + }); + + afterEach(() => { + for (const root of tempRoots.splice(0)) { + rmSync(root, { force: true, recursive: true }); + } + }); + + it("passes when public package catalog groups match architecture policy groups", () => { + const root = createTempRoot(); + writePackage(root, "alpha"); + writePackage(root, "provider"); + writePackageCatalog(root, { + Core: ["alpha"], + Provider: ["provider"], + }); + writeArchitectureManifest(root, { + framework: { packages: ["@croco/alpha"] }, + integrations: { packages: ["@croco/provider"] }, + }); + + const result = runScript(root); + + expect(result.status).toBe(0); + expect(result.output).toContain( + "architecture-policy: package catalog group consistency passed", + ); + }); + + it("fails when a public package is missing architecture policy classification", () => { + const root = createTempRoot(); + writePackage(root, "alpha"); + writePackageCatalog(root, { + Core: ["alpha"], + }); + writeArchitectureManifest(root, { + framework: { packages: [] }, + }); + + const result = runScript(root); + + expect(result.status).toBe(1); + expect(result.output).toContain( + "public package @croco/alpha is not classified by croco.arch.json packageGroups", + ); + }); + + it("fails when a public package is missing package catalog group metadata", () => { + const root = createTempRoot(); + writePackage(root, "alpha"); + writePackageCatalog(root, { + Core: [], + }); + writeArchitectureManifest(root, { + framework: { packages: ["@croco/alpha"] }, + }); + + const result = runScript(root); + + expect(result.status).toBe(1); + expect(result.output).toContain( + "public package alpha is missing package catalog group metadata", + ); + }); + + it("fails with an actionable diagnostic when the package catalog file is missing", () => { + const root = createTempRoot(); + writePackage(root, "alpha"); + writeArchitectureManifest(root, { + framework: { packages: ["@croco/alpha"] }, + }); + + const result = runScript(root); + + expect(result.status).toBe(1); + expect(result.output).toContain("docs/package-catalog.json is missing"); + expect(result.output).toContain( + "Restore docs/package-catalog.json before running architecture policy checks.", + ); + }); + + it("fails when one public package appears in multiple catalog groups", () => { + const root = createTempRoot(); + writePackage(root, "alpha"); + writePackageCatalog(root, { + Core: ["alpha"], + Domain: ["alpha"], + }); + writeArchitectureManifest(root, { + framework: { packages: ["@croco/alpha"] }, + }); + + const result = runScript(root); + + expect(result.status).toBe(1); + expect(result.output).toContain("package alpha appears in multiple package catalog groups"); + }); + + it("fails when one public package matches multiple architecture policy groups", () => { + const root = createTempRoot(); + writePackage(root, "alpha"); + writePackageCatalog(root, { + Core: ["alpha"], + }); + writeArchitectureManifest(root, { + app: { packages: ["@croco/alpha"] }, + framework: { packages: ["@croco/alpha"] }, + }); + + const result = runScript(root); + + expect(result.status).toBe(1); + expect(result.output).toContain( + "public package @croco/alpha matches multiple croco.arch.json packageGroups", + ); + }); + + it("fails when catalog and architecture groups differ without an override", () => { + const root = createTempRoot(); + writePackage(root, "alpha"); + writePackageCatalog(root, { + Tooling: ["alpha"], + }); + writeArchitectureManifest(root, { + framework: { packages: ["@croco/alpha"] }, + }); + + const result = runScript(root); + + expect(result.status).toBe(1); + expect(result.output).toContain( + "package @croco/alpha catalog group Tooling maps to policy group app but croco.arch.json assigns framework", + ); + }); + + it("uses manifest packageRoots when discovering public packages", () => { + const root = createTempRoot(); + writePackage(root, "alpha", "libs"); + writePackageCatalog(root, { + Core: ["alpha"], + }); + writeArchitectureManifest( + root, + { + framework: { packages: ["@croco/alpha"] }, + }, + [], + ["libs"], + ); + + const result = runScript(root); + + expect(result.status).toBe(0); + expect(result.output).toContain( + "architecture-policy: package catalog group consistency passed for 1 public package(s)", + ); + }); + + it("allows explicit package-level overrides for intentional catalog and policy group differences", () => { + const root = createTempRoot(); + writePackage(root, "events-tx"); + writePackageCatalog(root, { + Core: ["events-tx"], + }); + writeArchitectureManifest( + root, + { + integrations: { packages: ["@croco/events-tx"] }, + }, + [ + { + package: "@croco/events-tx", + catalogGroup: "Core", + policyGroup: "integrations", + reason: "The transaction-backed event package is an adapter boundary.", + }, + ], + ); + + const result = runScript(root); + + expect(result.status).toBe(0); + }); + + it("fails stale overrides that no longer match the actual catalog and policy groups", () => { + const root = createTempRoot(); + writePackage(root, "events-tx"); + writePackageCatalog(root, { + Core: ["events-tx"], + }); + writeArchitectureManifest( + root, + { + framework: { packages: ["@croco/events-tx"] }, + }, + [ + { + package: "@croco/events-tx", + catalogGroup: "Core", + policyGroup: "integrations", + reason: "The transaction-backed event package is an adapter boundary.", + }, + ], + ); + + const result = runScript(root); + + expect(result.status).toBe(1); + expect(result.output).toContain( + "packageCatalogGroupOverrides entry for @croco/events-tx expects catalog=Core policy=integrations but actual catalog=Core policy=framework", + ); + }); +}); + +function runScript(root: string): ScriptResult { + const result = spawnSync( + process.execPath, + [ + "--experimental-strip-types", + scriptPath, + "--manifest", + join(root, "croco.arch.json"), + "--root", + root, + ], + { + encoding: "utf-8", + }, + ); + + return { + output: `${result.stdout}${result.stderr}`, + status: result.status, + }; +} + +function createTempRoot(): string { + const root = mkdtempSync(join(tmpdir(), "croco-architecture-policy-check-")); + tempRoots.push(root); + mkdirSync(join(root, "docs"), { recursive: true }); + mkdirSync(join(root, "packages"), { recursive: true }); + return root; +} + +function writePackage(root: string, shortName: string, packageRoot = "packages"): void { + const packageDir = join(root, packageRoot, shortName); + mkdirSync(packageDir, { recursive: true }); + writeJson(join(packageDir, "package.json"), { + name: `@croco/${shortName}`, + version: "0.0.0", + type: "module", + }); +} + +function writePackageCatalog( + root: string, + groups: Readonly>, +): void { + writeJson(join(root, "docs", "package-catalog.json"), { + schemaVersion: 1, + groups: Object.fromEntries( + Object.entries(groups).map(([groupName, packages]) => [ + groupName, + { + description: `${groupName} packages`, + packages, + }, + ]), + ), + }); +} + +function writeArchitectureManifest( + root: string, + packageGroups: Readonly>, + overrides: readonly PackageCatalogGroupOverride[] = [], + packageRoots: readonly string[] = ["packages"], +): void { + writeJson(join(root, "croco.arch.json"), { + schemaVersion: "croco.architecture-policy/v1", + packageRoots, + include: packageRoots.map((packageRoot) => `${packageRoot}/*/src/**/*.ts`), + ignore: [], + packageGroups, + ...(overrides.length > 0 ? { packageCatalogGroupOverrides: overrides } : {}), + rules: {}, + }); +} + +function writeJson(path: string, value: unknown): void { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`); +}