From e84b749ac90f8c4c046df236919eb8844cf3ce19 Mon Sep 17 00:00:00 2001 From: willbot Date: Fri, 17 Jul 2026 15:54:03 +0200 Subject: [PATCH 1/2] fix(lint): every module is classified and aliased to source 28 of 136 source files matched no glob in architecture.config.json, so they joined no module group and no domain/layer/plane rule applied to them. The storage and streams packages were unclassified entirely, including their entrypoint and server files, which boot the deployed runtime. Six workspace specifiers were also missing from tsconfig.depcruise.json. Those resolve through node_modules to the package exports map, which points at built dist; dist is excluded, so the cruiser dropped the edge and could not rule on it. Aliasing them to source makes 12 previously invisible edges visible. All of them turn out to be clean. Co-Authored-By: Claude Opus 4.8 Signed-off-by: willbot Signed-off-by: Will Madden --- architecture.config.json | 168 +++++++++++++++++++++++++++++++++++++++ tsconfig.depcruise.json | 14 ++++ 2 files changed, 182 insertions(+) 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/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" ], From 2e4db4ae89dce454ff2babcf6797247379ecc476 Mon Sep 17 00:00:00 2001 From: willbot Date: Fri, 17 Jul 2026 15:54:16 +0200 Subject: [PATCH 2/2] feat(lint): the architecture gate fails closed on an edge it cannot see A new file under packages/ joined no module group and no rule applied to it, so a plane violation in it passed with a green gate. That was the default for every file added to the repo. A missing paths entry did the same thing by a different route: the import resolved to excluded dist and the edge vanished. lint-architecture-coverage.mjs now fails when a source file is unclassified or a workspace specifier is unaliased. It is a check rather than a default classification 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). It imports the cruisers own exclude list and shares its normalizeGlob, so the two cannot disagree about what is checked. The cruiser also cruised files its exclude list meant to skip: .test. missed .vitest.ts and vitest.config missed vitest.conformance.deployed.config.ts. Proven with a deliberate control: a new public file importing an execution module is red when unclassified and red on the plane rule once classified. Removing one paths entry turns the cruiser green on that same real violation, which the coverage check catches. Co-Authored-By: Claude Opus 4.8 Signed-off-by: willbot Signed-off-by: Will Madden --- dependency-cruiser.config.mjs | 26 +---- lint-staged.config.mjs | 3 + package.json | 2 +- scripts/architecture-coverage.mjs | 56 +++++++++++ scripts/lint-architecture-coverage.mjs | 89 +++++++++++++++++ scripts/lint-architecture-coverage.test.mjs | 103 ++++++++++++++++++++ 6 files changed, 255 insertions(+), 24 deletions(-) create mode 100644 scripts/architecture-coverage.mjs create mode 100644 scripts/lint-architecture-coverage.mjs create mode 100644 scripts/lint-architecture-coverage.test.mjs 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); + }); +});