From 5307b41f8cc3706f0d580c93f5edcc27b33d17c3 Mon Sep 17 00:00:00 2001 From: kang-heewon Date: Sat, 4 Jul 2026 01:26:53 +0900 Subject: [PATCH 1/4] fix: enforce architecture catalog group consistency --- croco.arch.json | 58 ++- scripts/architecture-policy-check.mts | 460 +++++++++++++++++- .../tests/architecture-policy-check.spec.ts | 285 +++++++++++ 3 files changed, 795 insertions(+), 8 deletions(-) create mode 100644 scripts/tests/architecture-policy-check.spec.ts diff --git a/croco.arch.json b/croco.arch.json index b427899d1..4a182fc44 100644 --- a/croco.arch.json +++ b/croco.arch.json @@ -18,23 +18,57 @@ "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/triggers-core", + "@croco/tx-core", + "@croco/webhooks-core", + "@croco/workflow-core" ] }, "protocols": { @@ -68,6 +102,7 @@ "@croco/storage-cloudflare", "@croco/storage-cloudinary", "@croco/storage-r2", + "@croco/telemetry-api", "@croco/telemetry-sdk-node", "@croco/tx-drizzle" ] @@ -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,20 @@ ] } }, + "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/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/scripts/architecture-policy-check.mts b/scripts/architecture-policy-check.mts index 932670f53..920506422 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 { 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,57 @@ 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 RawManifest = { + readonly packageCatalogGroupOverrides?: unknown; +}; + +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 +114,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 +148,396 @@ 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); + 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 rawManifest = readJsonFile(options.manifestPath); + const overrides = readPackageCatalogGroupOverrides(rawManifest, 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): readonly WorkspacePackage[] { + const packagesDir = join(rootDir, "packages"); + const entries = readdirSync(packagesDir, { withFileTypes: true }); + const packages: WorkspacePackage[] = []; + + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + + const packageJsonPath = join(packagesDir, entry.name, "package.json"); + if (!existsSync(packageJsonPath)) { + continue; + } + + 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: join("packages", entry.name), + shortName: toShortPackageName(packageJson.name), + }); + } + + return packages.sort((left, right) => left.shortName.localeCompare(right.shortName)); +} + +function readPackageCatalogGroups( + rootDir: string, + packageByShortName: ReadonlyMap, + violations: PackageCatalogGroupViolation[], +): ReadonlyMap { + const metadata = readJsonFile(join(rootDir, packageCatalogPath)); + 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( + rawManifest: RawManifest, + packageByName: ReadonlyMap, + violations: PackageCatalogGroupViolation[], +): PackageCatalogGroupOverride[] { + const value = rawManifest.packageCatalogGroupOverrides; + 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 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..0124ee218 --- /dev/null +++ b/scripts/tests/architecture-policy-check.spec.ts @@ -0,0 +1,285 @@ +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 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("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): void { + const packageDir = join(root, "packages", 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[] = [], +): void { + writeJson(join(root, "croco.arch.json"), { + schemaVersion: "croco.architecture-policy/v1", + packageRoots: ["packages"], + include: ["packages/*/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`); +} From 6addcb37691ba981076c2c791cb708979efc0800 Mon Sep 17 00:00:00 2001 From: kang-heewon Date: Sat, 4 Jul 2026 03:04:00 +0900 Subject: [PATCH 2/4] fix: align architecture catalog override handling --- croco.arch.json | 8 +- packages/architecture-policy/src/index.ts | 2 + scripts/architecture-policy-check.mts | 92 +++++++++++++------ .../tests/architecture-policy-check.spec.ts | 48 +++++++++- 4 files changed, 119 insertions(+), 31 deletions(-) diff --git a/croco.arch.json b/croco.arch.json index 4a182fc44..598640ee2 100644 --- a/croco.arch.json +++ b/croco.arch.json @@ -65,6 +65,7 @@ "@croco/storage-core", "@croco/tasks-core", "@croco/tenant-core", + "@croco/telemetry-api", "@croco/triggers-core", "@croco/tx-core", "@croco/webhooks-core", @@ -102,7 +103,6 @@ "@croco/storage-cloudflare", "@croco/storage-cloudinary", "@croco/storage-r2", - "@croco/telemetry-api", "@croco/telemetry-sdk-node", "@croco/tx-drizzle" ] @@ -138,6 +138,12 @@ "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", 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/scripts/architecture-policy-check.mts b/scripts/architecture-policy-check.mts index 920506422..81bce6587 100644 --- a/scripts/architecture-policy-check.mts +++ b/scripts/architecture-policy-check.mts @@ -1,7 +1,7 @@ #!/usr/bin/env node import { existsSync, readdirSync, readFileSync } from "node:fs"; -import { join, relative, resolve } from "node:path"; +import { dirname, join, relative, resolve } from "node:path"; import { checkArchitecturePolicy, formatArchitecturePolicyDiagnostic, @@ -46,10 +46,6 @@ type PackageCatalogGroupConsistencyReport = { readonly violations: readonly PackageCatalogGroupViolation[]; }; -type RawManifest = { - readonly packageCatalogGroupOverrides?: unknown; -}; - type CatalogMetadata = { readonly groups?: unknown; }; @@ -182,12 +178,18 @@ function checkPackageCatalogGroupConsistency(options: { }): PackageCatalogGroupConsistencyReport { const rootDir = resolve(options.rootDir); const violations: PackageCatalogGroupViolation[] = []; - const packages = readPublicWorkspacePackages(rootDir); + 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 rawManifest = readJsonFile(options.manifestPath); - const overrides = readPackageCatalogGroupOverrides(rawManifest, packageByName, violations); + const overrides = readPackageCatalogGroupOverrides( + options.manifest.packageCatalogGroupOverrides, + packageByName, + violations, + ); const overridesByPackage = new Map(overrides.map((override) => [override.packageName, override])); for (const pkg of packages) { @@ -283,21 +285,15 @@ function checkPackageCatalogGroupConsistency(options: { }; } -function readPublicWorkspacePackages(rootDir: string): readonly WorkspacePackage[] { - const packagesDir = join(rootDir, "packages"); - const entries = readdirSync(packagesDir, { withFileTypes: true }); +function readPublicWorkspacePackages( + rootDir: string, + packageRoots: readonly string[], +): readonly WorkspacePackage[] { const packages: WorkspacePackage[] = []; - for (const entry of entries) { - if (!entry.isDirectory()) { - continue; - } - - const packageJsonPath = join(packagesDir, entry.name, "package.json"); - if (!existsSync(packageJsonPath)) { - continue; - } - + 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`); @@ -309,12 +305,15 @@ function readPublicWorkspacePackages(rootDir: string): readonly WorkspacePackage packages.push({ name: packageJson.name, - relativeDir: join("packages", entry.name), + relativeDir: toPosixPath(relative(rootDir, dirname(packageJsonPath))), shortName: toShortPackageName(packageJson.name), }); } - return packages.sort((left, right) => left.shortName.localeCompare(right.shortName)); + return packages.sort((left, right) => { + const byShortName = left.shortName.localeCompare(right.shortName); + return byShortName === 0 ? left.relativeDir.localeCompare(right.relativeDir) : byShortName; + }); } function readPackageCatalogGroups( @@ -322,7 +321,17 @@ function readPackageCatalogGroups( packageByShortName: ReadonlyMap, violations: PackageCatalogGroupViolation[], ): ReadonlyMap { - const metadata = readJsonFile(join(rootDir, packageCatalogPath)); + 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", @@ -373,11 +382,10 @@ function readPackageCatalogGroups( } function readPackageCatalogGroupOverrides( - rawManifest: RawManifest, + value: unknown, packageByName: ReadonlyMap, violations: PackageCatalogGroupViolation[], ): PackageCatalogGroupOverride[] { - const value = rawManifest.packageCatalogGroupOverrides; if (value === undefined) { return []; } @@ -499,6 +507,38 @@ 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)); } diff --git a/scripts/tests/architecture-policy-check.spec.ts b/scripts/tests/architecture-policy-check.spec.ts index 0124ee218..8438eb424 100644 --- a/scripts/tests/architecture-policy-check.spec.ts +++ b/scripts/tests/architecture-policy-check.spec.ts @@ -95,6 +95,22 @@ describe("architecture-policy-check.mts", () => { ); }); + 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"); @@ -149,6 +165,29 @@ describe("architecture-policy-check.mts", () => { ); }); + 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"); @@ -235,8 +274,8 @@ function createTempRoot(): string { return root; } -function writePackage(root: string, shortName: string): void { - const packageDir = join(root, "packages", shortName); +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}`, @@ -267,11 +306,12 @@ 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: ["packages"], - include: ["packages/*/src/**/*.ts"], + packageRoots, + include: packageRoots.map((packageRoot) => `${packageRoot}/*/src/**/*.ts`), ignore: [], packageGroups, ...(overrides.length > 0 ? { packageCatalogGroupOverrides: overrides } : {}), From abb82d0b075b99e6bcd091cf14f069f82a5d6132 Mon Sep 17 00:00:00 2001 From: kang-heewon Date: Sat, 4 Jul 2026 03:21:00 +0900 Subject: [PATCH 3/4] fix: release architecture policy manifest override contract --- .changeset/architecture-catalog-group-overrides.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/architecture-catalog-group-overrides.md 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. From e254b6e8be8b0269b174fc8a4b93bdaeb56d91f1 Mon Sep 17 00:00:00 2001 From: kang-heewon Date: Sat, 4 Jul 2026 08:43:05 +0900 Subject: [PATCH 4/4] fix: sync architecture policy manifest docs --- .../ArchitecturePolicyManifest.md | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) 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/)