diff --git a/architecture.config.json b/architecture.config.json index 5f07ee40..95ca9eb6 100644 --- a/architecture.config.json +++ b/architecture.config.json @@ -222,6 +222,42 @@ "layer": "extensions", "plane": "shared" }, + { + "glob": "packages/1-prisma-cloud/1-extensions/target/src/preflight.ts", + "domain": "prisma-cloud", + "layer": "extensions", + "plane": "control" + }, + { + "glob": "packages/1-prisma-cloud/1-extensions/target/src/s3-credentials-resource.ts", + "domain": "prisma-cloud", + "layer": "extensions", + "plane": "control" + }, + { + "glob": "packages/1-prisma-cloud/1-extensions/target/src/s3-credentials.ts", + "domain": "prisma-cloud", + "layer": "extensions", + "plane": "shared" + }, + { + "glob": "packages/1-prisma-cloud/1-extensions/target/src/s3-store.ts", + "domain": "prisma-cloud", + "layer": "extensions", + "plane": "shared" + }, + { + "glob": "packages/1-prisma-cloud/1-extensions/target/src/param.ts", + "domain": "prisma-cloud", + "layer": "extensions", + "plane": "shared" + }, + { + "glob": "packages/1-prisma-cloud/1-extensions/target/src/secret.ts", + "domain": "prisma-cloud", + "layer": "extensions", + "plane": "shared" + }, { "glob": "packages/1-prisma-cloud/2-shared-modules/cron/src/scheduler-service.ts", "domain": "prisma-cloud", @@ -270,6 +306,114 @@ "layer": "modules", "plane": "shared" }, + { + "glob": "packages/1-prisma-cloud/2-shared-modules/storage/src/index.ts", + "domain": "prisma-cloud", + "layer": "modules", + "plane": "shared" + }, + { + "glob": "packages/1-prisma-cloud/2-shared-modules/storage/src/contract.ts", + "domain": "prisma-cloud", + "layer": "modules", + "plane": "shared" + }, + { + "glob": "packages/1-prisma-cloud/2-shared-modules/storage/src/storage-module.ts", + "domain": "prisma-cloud", + "layer": "modules", + "plane": "shared" + }, + { + "glob": "packages/1-prisma-cloud/2-shared-modules/storage/src/storage-service.ts", + "domain": "prisma-cloud", + "layer": "modules", + "plane": "shared" + }, + { + "glob": "packages/1-prisma-cloud/2-shared-modules/storage/src/store.ts", + "domain": "prisma-cloud", + "layer": "modules", + "plane": "shared" + }, + { + "glob": "packages/1-prisma-cloud/2-shared-modules/storage/src/storage-entrypoint.ts", + "domain": "prisma-cloud", + "layer": "modules", + "plane": "execution" + }, + { + "glob": "packages/1-prisma-cloud/2-shared-modules/storage/src/storage-server.ts", + "domain": "prisma-cloud", + "layer": "modules", + "plane": "execution" + }, + { + "glob": "packages/1-prisma-cloud/2-shared-modules/storage/src/handler.ts", + "domain": "prisma-cloud", + "layer": "modules", + "plane": "execution" + }, + { + "glob": "packages/1-prisma-cloud/2-shared-modules/storage/src/sigv4.ts", + "domain": "prisma-cloud", + "layer": "modules", + "plane": "execution" + }, + { + "glob": "packages/1-prisma-cloud/2-shared-modules/storage/src/pg-store.ts", + "domain": "prisma-cloud", + "layer": "modules", + "plane": "execution" + }, + { + "glob": "packages/1-prisma-cloud/2-shared-modules/storage/src/memory-store.ts", + "domain": "prisma-cloud", + "layer": "modules", + "plane": "execution" + }, + { + "glob": "packages/1-prisma-cloud/2-shared-modules/storage/src/testing.ts", + "domain": "prisma-cloud", + "layer": "modules", + "plane": "execution" + }, + { + "glob": "packages/1-prisma-cloud/2-shared-modules/streams/src/index.ts", + "domain": "prisma-cloud", + "layer": "modules", + "plane": "shared" + }, + { + "glob": "packages/1-prisma-cloud/2-shared-modules/streams/src/contract.ts", + "domain": "prisma-cloud", + "layer": "modules", + "plane": "shared" + }, + { + "glob": "packages/1-prisma-cloud/2-shared-modules/streams/src/streams-module.ts", + "domain": "prisma-cloud", + "layer": "modules", + "plane": "shared" + }, + { + "glob": "packages/1-prisma-cloud/2-shared-modules/streams/src/streams-service.ts", + "domain": "prisma-cloud", + "layer": "modules", + "plane": "shared" + }, + { + "glob": "packages/1-prisma-cloud/2-shared-modules/streams/src/streams-entrypoint.ts", + "domain": "prisma-cloud", + "layer": "modules", + "plane": "execution" + }, + { + "glob": "packages/1-prisma-cloud/2-shared-modules/streams/src/testing.ts", + "domain": "prisma-cloud", + "layer": "modules", + "plane": "execution" + }, { "glob": "packages/9-public/composer/src/deploy.ts", "domain": "public", @@ -365,6 +509,30 @@ "domain": "public", "layer": "public", "plane": "shared" + }, + { + "glob": "packages/9-public/composer-prisma-cloud/src/storage.ts", + "domain": "public", + "layer": "public", + "plane": "shared" + }, + { + "glob": "packages/9-public/composer-prisma-cloud/src/streams.ts", + "domain": "public", + "layer": "public", + "plane": "shared" + }, + { + "glob": "packages/9-public/composer-prisma-cloud/src/storage-testing.ts", + "domain": "public", + "layer": "public", + "plane": "execution" + }, + { + "glob": "packages/9-public/composer-prisma-cloud/src/streams-testing.ts", + "domain": "public", + "layer": "public", + "plane": "execution" } ], "rules": { diff --git a/dependency-cruiser.config.mjs b/dependency-cruiser.config.mjs index ceb2d917..4b43ce69 100644 --- a/dependency-cruiser.config.mjs +++ b/dependency-cruiser.config.mjs @@ -16,6 +16,7 @@ */ import config from './architecture.config.json' with { type: 'json' }; +import { normalizeGlob } from './scripts/architecture-coverage.mjs'; const { packages: packageConfigs, @@ -25,28 +26,6 @@ const { crossDomainRules, } = config; -const normalizeGlob = (glob) => { - const DOUBLE_WILDCARD = '__DOUBLE_WILDCARD__'; - const SINGLE_WILDCARD = '__SINGLE_WILDCARD__'; - const hasWildcard = glob.includes('*'); - const lastPathSegment = glob.split('/').pop() ?? ''; - const isFileLikePattern = !hasWildcard && lastPathSegment.includes('.'); - - let pattern = glob - .replace(/\*\*/g, DOUBLE_WILDCARD) - .replace(/\*/g, SINGLE_WILDCARD) - .replaceAll(DOUBLE_WILDCARD, '.*') - .replaceAll(SINGLE_WILDCARD, '[^/]*'); - - if (isFileLikePattern) { - return `^${pattern}$`; - } - if (!hasWildcard && !pattern.endsWith('/')) { - pattern += '/.*'; - } - return `^${pattern}`; -}; - const moduleGroupMap = new Map(); for (const pkgConfig of packageConfigs) { @@ -233,8 +212,9 @@ export default { 'node_modules', '^packages/.*\\.test\\.', '^packages/.*\\.test-d\\.', + '^packages/.*\\.vitest\\.', '^packages/.*__tests__', - 'vitest\\.config', + 'vitest\\..*config', 'tsdown\\.config', 'next\\.config', '\\.d\\.ts$', diff --git a/lint-staged.config.mjs b/lint-staged.config.mjs index a69eca41..db05c49d 100644 --- a/lint-staged.config.mjs +++ b/lint-staged.config.mjs @@ -13,4 +13,7 @@ export default { '{packages,examples,test}/**/*.{ts,tsx}': () => 'pnpm lint:deps', 'architecture.config.json': () => 'pnpm lint:deps', 'dependency-cruiser.config.mjs': () => 'pnpm lint:deps', + // Dropping a `paths` entry makes the cruiser resolve that import to built + // dist, which is excluded — the edge vanishes and no rule fires on it. + 'tsconfig.depcruise.json': () => 'pnpm lint:deps', }; diff --git a/package.json b/package.json index 56644c49..9d53376e 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "typecheck": "turbo run typecheck", "clean": "turbo run clean", "prepare": "husky && skills add prisma/skills --skill '*' --agent universal claude-code -y && skills add ./skills-contrib --skill '*' --agent universal claude-code -y && node scripts/sync-agent-rules.mjs", - "lint:deps": "depcruise --config dependency-cruiser.config.mjs packages examples test website && node scripts/lint-publishable-location.mjs && node scripts/lint-framework-vocabulary.mjs" + "lint:deps": "depcruise --config dependency-cruiser.config.mjs packages examples test website && node scripts/lint-architecture-coverage.mjs && node scripts/lint-publishable-location.mjs && node scripts/lint-framework-vocabulary.mjs" }, "devDependencies": { "@biomejs/biome": "2.5.3", diff --git a/scripts/architecture-coverage.mjs b/scripts/architecture-coverage.mjs new file mode 100644 index 00000000..3887e3d8 --- /dev/null +++ b/scripts/architecture-coverage.mjs @@ -0,0 +1,56 @@ +/** + * Shared helpers for the architecture lint (ADR-0028). + * + * `normalizeGlob` is imported by both dependency-cruiser.config.mjs (which + * turns architecture.config.json into cruiser rules) and + * lint-architecture-coverage.mjs (which checks every source file is + * classified). If the check used its own matcher it could report a file as + * classified while the cruiser matched no group and applied no rule to it. + */ + +export const normalizeGlob = (glob) => { + const DOUBLE_WILDCARD = '__DOUBLE_WILDCARD__'; + const SINGLE_WILDCARD = '__SINGLE_WILDCARD__'; + const hasWildcard = glob.includes('*'); + const lastPathSegment = glob.split('/').pop() ?? ''; + const isFileLikePattern = !hasWildcard && lastPathSegment.includes('.'); + + let pattern = glob + .replace(/\*\*/g, DOUBLE_WILDCARD) + .replace(/\*/g, SINGLE_WILDCARD) + .replaceAll(DOUBLE_WILDCARD, '.*') + .replaceAll(SINGLE_WILDCARD, '[^/]*'); + + if (isFileLikePattern) { + return `^${pattern}$`; + } + if (!hasWildcard && !pattern.endsWith('/')) { + pattern += '/.*'; + } + return `^${pattern}`; +}; + +export const findUnclassifiedFiles = (files, packageConfigs) => { + const matchers = packageConfigs.map((pkgConfig) => new RegExp(normalizeGlob(pkgConfig.glob))); + return files.filter((file) => !matchers.some((matcher) => matcher.test(file))); +}; + +const IMPORT_SPECIFIER = /(?:from|import)\s*\(?\s*['"]([^'"]+)['"]/g; + +export const readImportSpecifiers = (source) => + [...source.matchAll(IMPORT_SPECIFIER)].map((match) => match[1]); + +/** + * Workspace specifiers that `paths` does not alias to source. Their package + * `exports` maps point at built dist, which the cruiser excludes, so the edge + * is dropped and no rule can fire on it. + */ +export const findUnaliasedSpecifiers = (specifiers, workspacePackageNames, paths) => { + const belongsToWorkspace = (specifier) => + workspacePackageNames.some((name) => specifier === name || specifier.startsWith(`${name}/`)); + + return [...new Set(specifiers)] + .filter(belongsToWorkspace) + .filter((specifier) => !Object.hasOwn(paths, specifier)) + .sort(); +}; diff --git a/scripts/lint-architecture-coverage.mjs b/scripts/lint-architecture-coverage.mjs new file mode 100644 index 00000000..7b7f7312 --- /dev/null +++ b/scripts/lint-architecture-coverage.mjs @@ -0,0 +1,89 @@ +#!/usr/bin/env node +// Architecture-coverage gate (ADR-0028): makes the dependency-cruiser rules +// fail-closed. The cruiser can only rule on an edge it can see, and each of +// these was independently enough to let a plane violation land on a green gate: +// +// 1. Classification — a file matching no glob in architecture.config.json +// joins no module group, so NO domain/layer/plane rule applies to it. +// This is the default for every newly added file. +// 2. Aliasing — a workspace specifier missing from tsconfig.depcruise.json +// `paths` resolves through node_modules to the package's `exports` map, +// which points at built dist. dist is excluded, so the edge is dropped +// entirely and no rule can fire on it. +// +// These are checks rather than defaults because neither can be guessed: a +// plane is a judgement about when code runs, and a file wrongly defaulted to +// `shared` would also weaken the rules applied to files importing it +// (ADR-0005 — we don't guess). +// +// Wired into `pnpm lint:deps` (local, lint-staged, and CI). +import { execFileSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; + +import architectureConfig from '../architecture.config.json' with { type: 'json' }; +import cruiserConfig from '../dependency-cruiser.config.mjs'; +import depcruiseTsConfig from '../tsconfig.depcruise.json' with { type: 'json' }; +import { + findUnaliasedSpecifiers, + findUnclassifiedFiles, + readImportSpecifiers, +} from './architecture-coverage.mjs'; + +// `--others` so a new, not-yet-staged file is checked too: an unclassified file +// is only ever a new one, and waiting for `git add` would miss it exactly when +// the check is most needed. `--exclude-standard` keeps .gitignore respected. +const gitLsFiles = (...globs) => + execFileSync('git', ['ls-files', '--cached', '--others', '--exclude-standard', ...globs], { + encoding: 'utf8', + }) + .split('\n') + .filter(Boolean); + +// The cruiser's own exclusions, so the two always agree on what is cruised. +const excluded = cruiserConfig.options.exclude.path.map((pattern) => new RegExp(pattern)); + +const sourceFiles = gitLsFiles('packages/**/*.ts', 'packages/**/*.mjs').filter( + (file) => !excluded.some((pattern) => pattern.test(file)), +); + +const workspacePackageNames = gitLsFiles('packages/**/package.json') + .filter((file) => !file.includes('node_modules')) + .map((file) => JSON.parse(readFileSync(file, 'utf8')).name) + .filter(Boolean); + +const specifiers = sourceFiles.flatMap((file) => readImportSpecifiers(readFileSync(file, 'utf8'))); + +const failures = []; + +const unclassified = findUnclassifiedFiles(sourceFiles, architectureConfig.packages); +if (unclassified.length > 0) { + failures.push( + `Unclassified source files (${unclassified.length}) — no architecture rule applies to these:\n` + + unclassified.map((file) => ` - ${file}`).join('\n') + + '\n Add each to architecture.config.json with its {domain, layer, plane}.\n' + + ' Plane: `control` = deploy-time, `execution` = runs in the deployed app, `shared` = both.', + ); +} + +const unaliased = findUnaliasedSpecifiers( + specifiers, + workspacePackageNames, + depcruiseTsConfig.compilerOptions.paths, +); +if (unaliased.length > 0) { + failures.push( + `Unaliased workspace specifiers (${unaliased.length}) — the cruiser cannot see these edges:\n` + + unaliased.map((specifier) => ` - ${specifier}`).join('\n') + + '\n Add each to tsconfig.depcruise.json `paths`, pointing at the source file.\n' + + ' Without it the import resolves to built dist, which is excluded, and the edge is dropped.', + ); +} + +if (failures.length > 0) { + console.error(`${failures.join('\n\n')}\n`); + process.exit(1); +} +console.log( + `✔ architecture-coverage: ${sourceFiles.length} source files classified, ` + + `${new Set(specifiers).size} specifiers checked — every edge is visible to the cruiser`, +); diff --git a/scripts/lint-architecture-coverage.test.mjs b/scripts/lint-architecture-coverage.test.mjs new file mode 100644 index 00000000..a97be8ae --- /dev/null +++ b/scripts/lint-architecture-coverage.test.mjs @@ -0,0 +1,103 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + findUnaliasedSpecifiers, + findUnclassifiedFiles, + normalizeGlob, + readImportSpecifiers, +} from './architecture-coverage.mjs'; + +const PACKAGE_CONFIGS = [ + { glob: 'packages/9-public/composer/src/index.ts', domain: 'public', plane: 'shared' }, + { glob: 'packages/0-framework/0-foundation/**', domain: 'framework', plane: 'shared' }, +]; + +describe('findUnclassifiedFiles', () => { + it('reports a new file that matches no glob', () => { + // The regression: a new public file joins no module group, so the cruiser + // applies no plane rule and a violation in it passes on a green gate. + assert.deepEqual( + findUnclassifiedFiles(['packages/9-public/composer/src/report.ts'], PACKAGE_CONFIGS), + ['packages/9-public/composer/src/report.ts'], + ); + }); + + it('accepts a file listed individually', () => { + assert.deepEqual( + findUnclassifiedFiles(['packages/9-public/composer/src/index.ts'], PACKAGE_CONFIGS), + [], + ); + }); + + it('accepts a file covered by a directory glob', () => { + assert.deepEqual( + findUnclassifiedFiles( + ['packages/0-framework/0-foundation/foundation/src/casts.ts'], + PACKAGE_CONFIGS, + ), + [], + ); + }); + + it('does not let a path merely containing a glob pass', () => { + assert.deepEqual( + findUnclassifiedFiles(['vendor/packages/9-public/composer/src/index.ts'], PACKAGE_CONFIGS), + ['vendor/packages/9-public/composer/src/index.ts'], + ); + }); +}); + +describe('readImportSpecifiers', () => { + it('reads static, type, re-export and dynamic specifiers', () => { + const source = [ + "import { a } from '@internal/core';", + "import type { B } from '@internal/storage';", + "export * from '@internal/streams';", + "const c = await import('@internal/cron');", + ].join('\n'); + + assert.deepEqual(readImportSpecifiers(source), [ + '@internal/core', + '@internal/storage', + '@internal/streams', + '@internal/cron', + ]); + }); +}); + +describe('findUnaliasedSpecifiers', () => { + const WORKSPACE = ['@internal/storage', '@internal/core']; + const PATHS = { '@internal/core': ['./packages/0-framework/1-core/core/src/index.ts'] }; + + it('reports a workspace specifier missing from paths', () => { + // Without a paths entry the import resolves to the package's exports map + // (built dist), which the cruiser excludes — the edge is dropped and no + // rule can fire on it. + assert.deepEqual(findUnaliasedSpecifiers(['@internal/storage'], WORKSPACE, PATHS), [ + '@internal/storage', + ]); + }); + + it('reports an unaliased subpath even when the bare package is aliased', () => { + assert.deepEqual(findUnaliasedSpecifiers(['@internal/core/testing'], WORKSPACE, PATHS), [ + '@internal/core/testing', + ]); + }); + + it('accepts an aliased specifier', () => { + assert.deepEqual(findUnaliasedSpecifiers(['@internal/core'], WORKSPACE, PATHS), []); + }); + + it('ignores third-party specifiers', () => { + assert.deepEqual(findUnaliasedSpecifiers(['node:fs', 'alchemy'], WORKSPACE, PATHS), []); + }); +}); + +describe('normalizeGlob', () => { + it('anchors a file pattern so it cannot match a longer path', () => { + const pattern = new RegExp(normalizeGlob('packages/9-public/composer/src/node.ts')); + assert.equal(pattern.test('packages/9-public/composer/src/node.ts'), true); + assert.equal(pattern.test('packages/9-public/composer/src/node-control.ts'), false); + }); +}); diff --git a/tsconfig.depcruise.json b/tsconfig.depcruise.json index 885e887c..48e1871b 100644 --- a/tsconfig.depcruise.json +++ b/tsconfig.depcruise.json @@ -7,6 +7,9 @@ "@internal/foundation/assertions": [ "./packages/0-framework/0-foundation/foundation/src/assertions.ts" ], + "@internal/foundation/secret": [ + "./packages/0-framework/0-foundation/foundation/src/secret.ts" + ], "@internal/tsdown-config": ["./packages/0-framework/0-foundation/tsdown-config/base.ts"], "@internal/core/config": ["./packages/0-framework/1-core/core/src/app-config.ts"], "@internal/core/deploy": ["./packages/0-framework/1-core/core/src/deploy.ts"], @@ -39,7 +42,18 @@ "@internal/prisma-cloud/testing": [ "./packages/1-prisma-cloud/1-extensions/target/src/testing.ts" ], + "@internal/prisma-cloud/connection": [ + "./packages/1-prisma-cloud/1-extensions/target/src/pg-connection.ts" + ], "@internal/prisma-cloud": ["./packages/1-prisma-cloud/1-extensions/target/src/index.ts"], + "@internal/storage/testing": [ + "./packages/1-prisma-cloud/2-shared-modules/storage/src/testing.ts" + ], + "@internal/storage": ["./packages/1-prisma-cloud/2-shared-modules/storage/src/index.ts"], + "@internal/streams/testing": [ + "./packages/1-prisma-cloud/2-shared-modules/streams/src/testing.ts" + ], + "@internal/streams": ["./packages/1-prisma-cloud/2-shared-modules/streams/src/index.ts"], "@internal/cron/scheduler-service": [ "./packages/1-prisma-cloud/2-shared-modules/cron/src/scheduler-service.ts" ],