From 4f69e5ddaef988febaf11344a136d55ad3556277 Mon Sep 17 00:00:00 2001 From: lskramarov Date: Wed, 5 Aug 2026 19:02:07 +0300 Subject: [PATCH 1/2] fix(components)!: unresolvable peerDependencies break npm install (#DS-4889) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consumers hit "ERESOLVE unable to resolve dependency tree" on install. - @koobiq/icons peer was a major behind what `ng add` injects (^11.1.3 vs ^12.1.1), which breaks a plain Angular 20 app following the install guide - @angular/common and platform-browser were imported but never declared, leaving npm free to mix Angular majors - the packager assigned a bare version, exact-pinning components and the date adapters to each other in a cycle; it now substitutes, so `^{{VERSION}}` works, and it fails the build if a placeholder survives - rxjs, marked, overlayscrollbars and @koobiq/date-adapter ship as external imports and are now declared; both adapters become optional peers, since nothing in the published bundles imports them - `ng add` now installs @angular/animations, a mandatory peer that the schematic never added Adds `check-peer-deps` (linters) and `check-npm-resolution` (build + publish), which resolves the built tarballs with npm. Yarn's node-modules linker downgrades these conflicts to warnings, which is why CI never caught them. BREAKING CHANGE: peerDependencies changed in every published package. @koobiq/angular-luxon-adapter and @koobiq/angular-moment-adapter are now optional peers and are no longer installed automatically — add the one you use explicitly. @angular/cdk, @angular/animations and overlayscrollbars must be present in the consuming application. --- .github/workflows/build.yml | 3 + .github/workflows/linters.yml | 1 + .github/workflows/publish.yml | 3 + docs/guides/installation.en.md | 23 ++- docs/guides/installation.ru.md | 23 ++- package.json | 4 + packages/angular-luxon-adapter/package.json | 2 +- packages/angular-moment-adapter/package.json | 2 +- packages/components-experimental/package.json | 2 +- packages/components/package.json | 33 +++- packages/docs-examples/package.json | 8 +- packages/schematics/rollup.config.js | 1 + .../ng-add/__snapshots__/ng-add.spec.ts.snap | 1 + packages/schematics/src/ng-add/index.ts | 4 + tools/builders/packager/build.ts | 48 ++++- tools/check-npm-resolution/index.ts | 144 ++++++++++++++ tools/check-npm-resolution/tsconfig.json | 13 ++ tools/check-peer-deps/index.ts | 181 ++++++++++++++++++ tools/check-peer-deps/tsconfig.json | 13 ++ yarn.lock | 11 +- 20 files changed, 501 insertions(+), 19 deletions(-) create mode 100644 tools/check-npm-resolution/index.ts create mode 100644 tools/check-npm-resolution/tsconfig.json create mode 100644 tools/check-peer-deps/index.ts create mode 100644 tools/check-peer-deps/tsconfig.json diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 69b0c5be70..753aa83bc1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -13,4 +13,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: ./.github/workflows/actions/setup-node - uses: ./.github/workflows/actions/build-packages + # Yarn's node-modules linker tolerates peer conflicts that npm rejects, so the built packages + # have to be resolved with npm to catch an ERESOLVE before it reaches consumers. + - run: yarn run check-npm-resolution - uses: ./.github/workflows/actions/build-docs diff --git a/.github/workflows/linters.yml b/.github/workflows/linters.yml index e1bb0ccd8a..9ada8a9a74 100644 --- a/.github/workflows/linters.yml +++ b/.github/workflows/linters.yml @@ -20,3 +20,4 @@ jobs: yarn run prettier yarn run stylelint --max-warnings=0 yarn run eslint --max-warnings=0 + yarn run check-peer-deps diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 20671ed177..ee6d752391 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -26,6 +26,9 @@ jobs: - name: Build packages uses: ./.github/workflows/actions/build-packages + - name: Check npm can resolve the packages + run: yarn run check-npm-resolution + - name: Publish package run: | rm -rf .npmrc && echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN_KOOBIQ" > ~/.npmrc diff --git a/docs/guides/installation.en.md b/docs/guides/installation.en.md index cc0d9038e2..7a18ae8ab9 100644 --- a/docs/guides/installation.en.md +++ b/docs/guides/installation.en.md @@ -11,9 +11,30 @@ ng add @koobiq/components Manual installation: ```bash -npm install @koobiq/components @koobiq/icons @koobiq/design-tokens @koobiq/angular-luxon-adapter @koobiq/date-adapter @koobiq/date-formatter luxon +npm install @koobiq/components @angular/cdk @angular/animations overlayscrollbars @koobiq/icons @koobiq/design-tokens @koobiq/angular-luxon-adapter @koobiq/date-adapter @koobiq/date-formatter luxon ``` +`@koobiq/angular-luxon-adapter` (or `@koobiq/angular-moment-adapter`) is only needed if you use the +date components — [datepicker](/en/components/datepicker), [timepicker](/en/components/timepicker) or +[filter-bar](/en/components/filter-bar). Install `marked` if you use +[markdown](/en/components/markdown), and `highlight.js` if you use +[code-block](/en/components/code-block). + +### Setting up animations + +The components use Angular animations, so the application must provide them: + +```typescript +import { provideAnimations } from '@angular/platform-browser/animations'; + +bootstrapApplication(AppComponent, { + providers: [provideAnimations()] +}); +``` + +Without this provider, opening a component that animates — dropdown, select, tooltip, toast, +datepicker — fails with `NG05105: Found the synthetic property @state`. + ### Setting up styles After installation, you need to include the library styles. Add the following files to the `styles` array in your `angular.json` file: diff --git a/docs/guides/installation.ru.md b/docs/guides/installation.ru.md index 03a717142e..d20a754011 100644 --- a/docs/guides/installation.ru.md +++ b/docs/guides/installation.ru.md @@ -11,9 +11,30 @@ ng add @koobiq/components Ручная установка: ```bash -npm install @koobiq/components @koobiq/icons @koobiq/design-tokens @koobiq/angular-luxon-adapter @koobiq/date-adapter @koobiq/date-formatter luxon +npm install @koobiq/components @angular/cdk @angular/animations overlayscrollbars @koobiq/icons @koobiq/design-tokens @koobiq/angular-luxon-adapter @koobiq/date-adapter @koobiq/date-formatter luxon ``` +`@koobiq/angular-luxon-adapter` (или `@koobiq/angular-moment-adapter`) нужен только при использовании +компонентов для работы с датами — [datepicker](/ru/components/datepicker), +[timepicker](/ru/components/timepicker) или [filter-bar](/ru/components/filter-bar). Установите +`marked`, если используете [markdown](/ru/components/markdown), и `highlight.js`, если используете +[code-block](/ru/components/code-block). + +### Настройка анимаций + +Компоненты используют анимации Angular, поэтому приложение должно их предоставить: + +```typescript +import { provideAnimations } from '@angular/platform-browser/animations'; + +bootstrapApplication(AppComponent, { + providers: [provideAnimations()] +}); +``` + +Без этого провайдера открытие компонента с анимацией — dropdown, select, tooltip, toast, +datepicker — завершится ошибкой `NG05105: Found the synthetic property @state`. + ### Настройка стилей После установки необходимо подключить стили библиотеки. Добавьте следующие файлы в массив `styles` вашего файла `angular.json`: diff --git a/package.json b/package.json index efd70ea792..bd08a158e9 100644 --- a/package.json +++ b/package.json @@ -92,6 +92,7 @@ "@types/merge2": "^0.3.30", "@types/node": "^24.10.4", "@types/nunjucks": "^3.2.1", + "@types/semver": "^7.7.1", "@types/spdx-satisfies": "^0.1.2", "angular-eslint": "^20.7.0", "autoprefixer": "^10.5.2", @@ -137,6 +138,7 @@ "rollup-plugin-terser": "^7.0.2", "rollup-plugin-typescript2": "^0.37.0", "sass": "^1.93.3", + "semver": "^7.8.1", "spdx-satisfies": "^5.0.1", "style-dictionary": "^3.7.1", "stylelint": "^17.14.1", @@ -296,6 +298,8 @@ "approve-api": "ts-node --project tools/api-extractor/tsconfig.json tools/api-extractor/api-extractor.ts", "check-api": "yarn run approve-api onlyCheck", "-----LINTERS-----": "----------------------------------------------------------------------------------------", + "check-peer-deps": "ts-node --project tools/check-peer-deps/tsconfig.json tools/check-peer-deps", + "check-npm-resolution": "ts-node --project tools/check-npm-resolution/tsconfig.json tools/check-npm-resolution", "eslint": "eslint .", "eslint:fix": "yarn run eslint --fix", "stylelint": "stylelint '**/*.{css,scss}'", diff --git a/packages/angular-luxon-adapter/package.json b/packages/angular-luxon-adapter/package.json index 71a06aed1f..26016eae3e 100644 --- a/packages/angular-luxon-adapter/package.json +++ b/packages/angular-luxon-adapter/package.json @@ -14,7 +14,7 @@ "license": "MIT", "peerDependencies": { "@koobiq/luxon-date-adapter": "^3.1.4", - "@koobiq/components": "{{VERSION}}" + "@koobiq/components": "^{{VERSION}}" }, "dependencies": { "tslib": "^2.6.2" diff --git a/packages/angular-moment-adapter/package.json b/packages/angular-moment-adapter/package.json index ba0297b6a0..450b153586 100644 --- a/packages/angular-moment-adapter/package.json +++ b/packages/angular-moment-adapter/package.json @@ -14,7 +14,7 @@ "license": "MIT", "peerDependencies": { "@koobiq/moment-date-adapter": "^3.1.4", - "@koobiq/components": "{{VERSION}}" + "@koobiq/components": "^{{VERSION}}" }, "dependencies": { "tslib": "^2.6.2" diff --git a/packages/components-experimental/package.json b/packages/components-experimental/package.json index 0207cfbf9b..11572b705f 100644 --- a/packages/components-experimental/package.json +++ b/packages/components-experimental/package.json @@ -25,7 +25,7 @@ "@angular/cdk": "{{NG_VERSION}}", "@angular/core": "{{NG_VERSION}}", "@angular/forms": "{{NG_VERSION}}", - "@koobiq/components": "{{VERSION}}" + "@koobiq/components": "^{{VERSION}}" }, "dependencies": { "tslib": "^2.6.2" diff --git a/packages/components/package.json b/packages/components/package.json index 5a3948731f..191b0160c2 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -23,13 +23,38 @@ "peerDependencies": { "@angular/animations": "{{NG_VERSION}}", "@angular/cdk": "{{NG_VERSION}}", + "@angular/common": "{{NG_VERSION}}", "@angular/core": "{{NG_VERSION}}", "@angular/forms": "{{NG_VERSION}}", - "@koobiq/angular-moment-adapter": "{{VERSION}}", - "@koobiq/angular-luxon-adapter": "{{VERSION}}", + "@angular/platform-browser": "{{NG_VERSION}}", + "@angular/router": "{{NG_VERSION}}", + "@koobiq/angular-moment-adapter": "^{{VERSION}}", + "@koobiq/angular-luxon-adapter": "^{{VERSION}}", + "@koobiq/date-adapter": "^3.0.0", "@koobiq/date-formatter": "^3.2.3", - "@koobiq/icons": "^11.1.3", - "@koobiq/design-tokens": "^3.14.0" + "@koobiq/icons": ">=11.1.3 <13.0.0", + "@koobiq/design-tokens": "^3.14.0", + "highlight.js": "^11.11.1", + "marked": "^17.0.0", + "overlayscrollbars": "^2.7.3", + "rxjs": "^6.5.3 || ^7.4.0" + }, + "peerDependenciesMeta": { + "@angular/router": { + "optional": true + }, + "@koobiq/angular-moment-adapter": { + "optional": true + }, + "@koobiq/angular-luxon-adapter": { + "optional": true + }, + "highlight.js": { + "optional": true + }, + "marked": { + "optional": true + } }, "dependencies": { "tslib": "^2.6.2" diff --git a/packages/docs-examples/package.json b/packages/docs-examples/package.json index 2ae6b0f488..6024e1af52 100644 --- a/packages/docs-examples/package.json +++ b/packages/docs-examples/package.json @@ -9,12 +9,14 @@ ], "license": "MIT", "peerDependencies": { + "@angular/animations": "{{NG_VERSION}}", "@angular/cdk": "{{NG_VERSION}}", "@angular/core": "{{NG_VERSION}}", "@angular/common": "{{NG_VERSION}}", - "@koobiq/components": "{{VERSION}}", - "@koobiq/angular-moment-adapter": "{{VERSION}}", - "@koobiq/angular-luxon-adapter": "{{VERSION}}" + "@angular/forms": "{{NG_VERSION}}", + "@koobiq/components": "^{{VERSION}}", + "@koobiq/angular-moment-adapter": "^{{VERSION}}", + "@koobiq/angular-luxon-adapter": "^{{VERSION}}" }, "dependencies": { "tslib": "^2.6.2" diff --git a/packages/schematics/rollup.config.js b/packages/schematics/rollup.config.js index 0640a8d573..faa314bdb1 100644 --- a/packages/schematics/rollup.config.js +++ b/packages/schematics/rollup.config.js @@ -55,6 +55,7 @@ module.exports = [ clean(), replace({ preventAssignment: true, + 'VERSIONS.ANGULAR_ANIMATIONS': version(pkg.dependencies['@angular/animations']), 'VERSIONS.ANGULAR_CDK': version(pkg.dependencies['@angular/cdk']), 'VERSIONS.KOOBIQ_CDK': version(pkg.version), 'VERSIONS.KOOBIQ_ANGULAR_LUXON_ADAPTER': version(pkg.version), diff --git a/packages/schematics/src/ng-add/__snapshots__/ng-add.spec.ts.snap b/packages/schematics/src/ng-add/__snapshots__/ng-add.spec.ts.snap index 7fe493ef7a..ffb061c534 100644 --- a/packages/schematics/src/ng-add/__snapshots__/ng-add.spec.ts.snap +++ b/packages/schematics/src/ng-add/__snapshots__/ng-add.spec.ts.snap @@ -2,6 +2,7 @@ exports[`ng add '@koobiq/components' should add missing dependencies to 'package.json': after running schematics 1`] = ` { + "@angular/animations": "^0.0.0", "@angular/cdk": "^0.0.0", "@angular/common": "^20.3.0", "@angular/compiler": "^20.3.0", diff --git a/packages/schematics/src/ng-add/index.ts b/packages/schematics/src/ng-add/index.ts index 393847112b..48f403a130 100644 --- a/packages/schematics/src/ng-add/index.ts +++ b/packages/schematics/src/ng-add/index.ts @@ -5,6 +5,7 @@ import * as messages from './messages'; import { Schema } from './schema'; const VERSIONS = { + ANGULAR_ANIMATIONS: '^0.0.0', ANGULAR_CDK: '^0.0.0', KOOBIQ_ANGULAR_LUXON_ADAPTER: '^0.0.0', KOOBIQ_DATE_FORMATTER: '^0.0.0', @@ -32,6 +33,9 @@ export default function ngAdd(options: Schema): Rule { } // Installing dependencies + // `@angular/animations` is a mandatory peer: the components declare `animations: [...]` + // metadata and bind synthetic `[@state]` properties, which throw NG05105 without it. + addPackageToPackageJson(tree, '@angular/animations', VERSIONS.ANGULAR_ANIMATIONS); addPackageToPackageJson(tree, '@angular/cdk', VERSIONS.ANGULAR_CDK); addPackageToPackageJson(tree, '@koobiq/angular-luxon-adapter', VERSIONS.KOOBIQ_ANGULAR_LUXON_ADAPTER); addPackageToPackageJson(tree, '@koobiq/date-formatter', VERSIONS.KOOBIQ_DATE_FORMATTER); diff --git a/tools/builders/packager/build.ts b/tools/builders/packager/build.ts index 5b1f7fcedd..30be062c42 100644 --- a/tools/builders/packager/build.ts +++ b/tools/builders/packager/build.ts @@ -88,6 +88,8 @@ export async function packager(options: IPackagerOptions, context: BuilderContex context.logger.info('Syncing Angular dependency versions for releasing...'); releasePackageJson = syncNgVersion(releasePackageJson, packageJson, options.ngVersionPlaceholder, context); + assertNoPlaceholders(releasePackageJson, releasePackageJsonPath); + writeFileSync(join(libraryDestination, 'package.json'), JSON.stringify(releasePackageJson, null, 4), { encoding: 'utf-8' }); @@ -126,10 +128,10 @@ interface INgPackagerJson { interface IPackageJson { version?: string; requiredAngularVersion: string; - peerDependencies: { + peerDependencies?: { [key: string]: string; }; - dependencies: { + dependencies?: { [key: string]: string; }; } @@ -147,8 +149,14 @@ function syncComponentsVersion( for (const [key, value] of Object.entries(releaseJson.peerDependencies!)) { if (value.includes(placeholder)) { - context.logger.info(`${key}: ${newPackageJson.version}`); - newPackageJson.peerDependencies![key] = `${newPackageJson.version}`; + // Substitute rather than overwrite, so the source manifest controls the shape of the + // range: `^{{VERSION}}` must publish as `^1.2.3`, not as the exact pin `1.2.3`. + // Exact-pinning our own packages to each other makes them mutually unsatisfiable as + // soon as their versions drift apart. + const range = value.replace(placeholder, newPackageJson.version!); + + context.logger.info(`${key}: ${range}`); + newPackageJson.peerDependencies![key] = range; } } } @@ -166,14 +174,42 @@ function syncNgVersion( for (const [key, value] of Object.entries(releaseJson.peerDependencies!)) { if (value.includes(placeholder)) { - context.logger.info(`${key}: ${rootPackageJson.requiredAngularVersion}`); - updatedJson.peerDependencies![key] = `${rootPackageJson.requiredAngularVersion}`; + const range = value.replace(placeholder, rootPackageJson.requiredAngularVersion); + + context.logger.info(`${key}: ${range}`); + updatedJson.peerDependencies![key] = range; } } return updatedJson; } +/** + * Fails the build if any `{{...}}` placeholder survived substitution. + * + * `syncComponentsVersion` only rewrites peers when the package version itself is still a + * placeholder, so a change in ng-packagr's output could silently leave `{{VERSION}}` in a + * published `peerDependencies` — an unsatisfiable range that breaks `npm install` for every + * consumer. Yarn's node-modules linker tolerates it in-repo, so nothing else would notice. + */ +function assertNoPlaceholders(releaseJson: IPackageJson, packageJsonPath: string) { + const leaked = Object.entries(releaseJson.peerDependencies || {}) + .concat(Object.entries(releaseJson.dependencies || {})) + .filter(([, value]) => value.includes('{{')) + .map(([key, value]) => `${key}: ${value}`); + + if (releaseJson.version?.includes('{{')) { + leaked.unshift(`version: ${releaseJson.version}`); + } + + if (leaked.length > 0) { + throw new Error( + `❌ Unresolved version placeholders in ${packageJsonPath}:\n ${leaked.join('\n ')}\n` + + 'Publishing this would produce an unsatisfiable dependency range.' + ); + } +} + async function tryJsonParse(path: string): Promise { try { return JSON.parse(await fs.readFile(path, { encoding: 'utf-8' })); diff --git a/tools/check-npm-resolution/index.ts b/tools/check-npm-resolution/index.ts new file mode 100644 index 0000000000..15a87ab6a7 --- /dev/null +++ b/tools/check-npm-resolution/index.ts @@ -0,0 +1,144 @@ +/** + * Resolves the built packages with **npm** before they are published. + * + * The repository installs with Yarn 4 (`nodeLinker: node-modules`), which downgrades peer conflicts + * to warnings, so a manifest that breaks `npm install` for every consumer looks perfectly healthy + * in CI. This check packs `dist/` exactly as `npm publish` would and asks npm to resolve the result + * against the project shapes consumers actually have. + * + * Runs on ubuntu in CI. Node >= 20.12 refuses to spawn `npm.cmd` without a shell, so on Windows the + * calls go through one — hence the quoting in `npm()`. + */ + +import { execFileSync, execSync } from 'child_process'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +interface PackageJson { + version?: string; + dependencies?: Record; + release?: { packages: string[] }; +} + +const projectRoot = join(__dirname, '..', '..'); +const distDir = join(projectRoot, 'dist'); + +const rootPackageJson: PackageJson = JSON.parse(readFileSync(join(projectRoot, 'package.json'), { encoding: 'utf-8' })); + +const angularVersion = rootPackageJson.dependencies!['@angular/core']; +const cdkVersion = rootPackageJson.dependencies!['@angular/cdk']; + +/** An Angular app of the given major, as `ng new` would leave it. */ +const angularApp = (versions: Record) => ({ + name: 'fixture', + version: '1.0.0', + private: true, + dependencies: { + '@angular/animations': versions.angular, + '@angular/cdk': versions.cdk, + '@angular/common': versions.angular, + '@angular/compiler': versions.angular, + '@angular/core': versions.angular, + '@angular/forms': versions.angular, + '@angular/platform-browser': versions.angular, + rxjs: '~7.8.0', + tslib: '^2.8.1' + } +}); + +const fixtures = [ + { + name: 'angular-20-app', + description: 'an existing Angular 20 application', + packageJson: angularApp({ angular: angularVersion, cdk: cdkVersion }), + extraInstalls: [] as string[] + }, + { + name: 'angular-20-app-latest-icons', + description: 'an Angular 20 application pulling the latest @koobiq/icons, as `ng add` does', + packageJson: angularApp({ angular: angularVersion, cdk: cdkVersion }), + extraInstalls: ['@koobiq/icons@latest'] + }, + { + name: 'documented-install', + description: 'the manual install line from docs/guides/installation.en.md', + packageJson: angularApp({ angular: angularVersion, cdk: cdkVersion }), + extraInstalls: [ + '@koobiq/icons', + '@koobiq/design-tokens', + '@koobiq/date-adapter', + '@koobiq/date-formatter', + 'luxon' + ] + } +]; + +const isWindows = process.platform === 'win32'; + +const npm = (args: string[], cwd: string): string => { + const encoding = 'utf-8'; + const stdio: ['ignore', 'pipe', 'pipe'] = ['ignore', 'pipe', 'pipe']; + + // Node >= 20.12 rejects spawning a `.cmd` without a shell, so Windows goes through `execSync`. + // Paths here come from `mkdtemp` and the workspace, both of which can contain spaces — quote + // every argument. + return isWindows + ? execSync(`npm.cmd ${args.map((arg) => `"${arg}"`).join(' ')}`, { cwd, encoding, stdio }) + : execFileSync('npm', args, { cwd, encoding, stdio }); +}; + +const packageNames = rootPackageJson.release?.packages || []; + +// `cli` has no peerDependencies and nothing depends on it; packing it adds nothing to resolve. +const packagesToCheck = packageNames.filter((name) => name !== 'cli'); + +const missing = packagesToCheck.filter((name) => !existsSync(join(distDir, name, 'package.json'))); + +if (missing.length > 0) { + console.error(`❌ Not built: ${missing.join(', ')}. Run the package builds before this check.`); + process.exit(1); +} + +const workDir = mkdtempSync(join(tmpdir(), 'koobiq-npm-resolution-')); + +console.log(`Packing ${packagesToCheck.length} package(s) from dist/...`); + +const tarballs = packagesToCheck.map((name) => { + const output = npm(['pack', join(distDir, name), '--pack-destination', workDir], projectRoot); + const tarball = output.trim().split('\n').pop()!; + + return join(workDir, tarball); +}); + +let failed = false; + +for (const fixture of fixtures) { + const fixtureDir = join(workDir, fixture.name); + + mkdirSync(fixtureDir, { recursive: true }); + writeFileSync(join(fixtureDir, 'package.json'), JSON.stringify(fixture.packageJson, null, 4)); + + // All tarballs in one command: the packages peer-depend on each other, so npm has to see them + // together to satisfy those peers from the local build rather than from the registry. + const args = ['install', '--dry-run', '--no-audit', '--no-fund', ...tarballs, ...fixture.extraInstalls]; + + try { + npm(args, fixtureDir); + console.log(` ✅ ${fixture.name} — ${fixture.description}`); + } catch (error: any) { + failed = true; + console.error(` ❌ ${fixture.name} — ${fixture.description}`); + console.error(String(error.stderr || error.message).replace(/^/gm, ' ')); + } +} + +if (failed) { + console.error( + '\nnpm cannot resolve the built packages. Consumers would get ' + + '"ERESOLVE unable to resolve dependency tree" on install.\n' + ); + process.exit(1); +} + +console.log('\n✅ npm resolves the built packages in every fixture.'); diff --git a/tools/check-npm-resolution/tsconfig.json b/tools/check-npm-resolution/tsconfig.json new file mode 100644 index 0000000000..2d4a3a84ec --- /dev/null +++ b/tools/check-npm-resolution/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "strict": true, + "target": "es2020", + "module": "commonjs", + "esModuleInterop": true, + "resolveJsonModule": true, + "noEmit": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["**/*.ts"] +} diff --git a/tools/check-peer-deps/index.ts b/tools/check-peer-deps/index.ts new file mode 100644 index 0000000000..36b4f7ca41 --- /dev/null +++ b/tools/check-peer-deps/index.ts @@ -0,0 +1,181 @@ +/** + * Validates the `peerDependencies` of every published package. + * + * The repository installs with Yarn 4 (`nodeLinker: node-modules`), which downgrades peer conflicts + * to warnings. npm >= 7 rejects them outright, so a manifest that is perfectly happy in-repo can + * still fail `npm install` for every consumer with `ERESOLVE unable to resolve dependency tree`. + * Nothing else in the release pipeline looks at peer ranges, so this is the only place that catches + * the drift before publishing. + */ + +import { execFileSync } from 'child_process'; +import { readFileSync } from 'fs'; +import { join } from 'path'; +import { subset, valid, validRange } from 'semver'; + +interface PackageJson { + name?: string; + version?: string; + requiredAngularVersion?: string; + dependencies?: Record; + devDependencies?: Record; + peerDependencies?: Record; + peerDependenciesMeta?: Record; + release?: { packages: string[] }; +} + +const projectRoot = join(__dirname, '..', '..'); +const versionPlaceholder = '{{VERSION}}'; +const ngVersionPlaceholder = '{{NG_VERSION}}'; + +const readJson = (path: string): PackageJson => JSON.parse(readFileSync(path, { encoding: 'utf-8' })); + +const rootPackageJson = readJson(join(projectRoot, 'package.json')); + +/** Source manifest of every package listed in the root `release.packages`. */ +const publishedPackages = (rootPackageJson.release?.packages || []) + .map((name) => ({ name, path: join(projectRoot, 'packages', name, 'package.json') })) + .map((pkg) => ({ ...pkg, json: readJson(pkg.path) })) + .filter((pkg) => pkg.json.peerDependencies !== undefined); + +/** + * Resolves the placeholders exactly as `tools/builders/packager/build.ts` does, so this check sees + * the same ranges the published manifest will carry. + */ +const resolvePlaceholders = (range: string): string => + range + .replace(versionPlaceholder, rootPackageJson.version!) + .replace(ngVersionPlaceholder, rootPackageJson.requiredAngularVersion!); + +/** + * Ranges that `ng add @koobiq/components` writes into the consumer's `package.json`. + * Mirrors `packages/schematics/rollup.config.js`, which injects them from the root manifest at + * build time — which is why a root bump silently desynchronizes from the peer ranges here. + */ +const caret = (range: string): string => (range.startsWith('^') ? range : `^${range}`); + +const schematicInjectedRanges: Record = { + '@angular/animations': caret(rootPackageJson.dependencies!['@angular/animations']), + '@angular/cdk': caret(rootPackageJson.dependencies!['@angular/cdk']), + '@koobiq/angular-luxon-adapter': caret(rootPackageJson.version!), + '@koobiq/date-formatter': caret(rootPackageJson.dependencies!['@koobiq/date-formatter']), + '@koobiq/date-adapter': caret(rootPackageJson.dependencies!['@koobiq/date-adapter']), + '@koobiq/icons': caret(rootPackageJson.dependencies!['@koobiq/icons']), + '@koobiq/design-tokens': caret(rootPackageJson.devDependencies!['@koobiq/design-tokens']) +}; + +const failures: string[] = []; +const fail = (pkg: string, message: string) => failures.push(`${pkg}: ${message}`); + +for (const pkg of publishedPackages) { + const peers = pkg.json.peerDependencies!; + + for (const [dependency, rawRange] of Object.entries(peers)) { + const range = resolvePlaceholders(rawRange); + + // A leaked `{{...}}` placeholder is not a valid range: npm can never satisfy it. + if (validRange(range) === null) { + fail(pkg.name, `peer "${dependency}": "${rawRange}" is not a valid semver range`); + continue; + } + + // An exact version pinned across our own packages makes them mutually unsatisfiable as soon + // as their versions drift — and they are released independently. + if (dependency.startsWith('@koobiq/') && valid(range) !== null) { + fail(pkg.name, `peer "${dependency}": "${range}" is an exact pin, use a range (e.g. "^${range}")`); + } + + // The schematic installs its own range into the consumer's package.json. If the peer range + // does not accept everything the schematic installs, `ng add` produces an unresolvable tree. + const injected = schematicInjectedRanges[dependency]; + + if (injected && !subset(injected, range)) { + fail( + pkg.name, + `peer "${dependency}": "${range}" does not accept "${injected}", which ` + + "`ng add` writes into the consumer's package.json (see packages/schematics/rollup.config.js)" + ); + } + } + + // An optional peer is a promise that the library still works without it. Anything statically + // imported by the published bundles must stay mandatory. + for (const dependency of Object.keys(pkg.json.peerDependenciesMeta || {})) { + if (!(dependency in peers)) { + fail(pkg.name, `peerDependenciesMeta lists "${dependency}", which is not a peerDependency`); + } + } +} + +/** Every module the published bundles import but do not declare is a "Module not found" for consumers. */ +const checkUndeclaredImports = () => { + const components = publishedPackages.find((pkg) => pkg.name === 'components'); + + if (!components) return; + + const declared = new Set([ + ...Object.keys(components.json.peerDependencies || {}), + ...Object.keys(components.json.dependencies || {}) + ]); + + let imports: string; + + try { + imports = execFileSync( + 'git', + [ + 'grep', + '-hoE', + "from '(@?[^.'][^']*)'", + '--', + 'packages/components/**/*.ts', + // Tests and dev harnesses are not published, so their imports carry no obligation. + ':!packages/components/**/*.spec.ts', + ':!packages/components/**/e2e.ts', + ':!packages/components/**/e2e.playwright-spec.ts' + ], + { cwd: projectRoot, encoding: 'utf-8' } + ); + } catch { + // `git grep` exits non-zero when nothing matches; nothing to validate in that case. + return; + } + + const undeclared = new Set(); + + for (const line of imports.split('\n')) { + const match = line.match(/from '([^']+)'/); + + if (!match) continue; + + const specifier = match[1]; + + // Reduce `@scope/pkg/entry-point` and `pkg/entry-point` to the installable package name. + const packageName = specifier.startsWith('@') + ? specifier.split('/').slice(0, 2).join('/') + : specifier.split('/')[0]; + + if (packageName.startsWith('@koobiq/components')) continue; + if (declared.has(packageName)) continue; + + undeclared.add(packageName); + } + + for (const packageName of [...undeclared].sort()) { + fail('components', `imports "${packageName}" but does not declare it as a dependency or peerDependency`); + } +}; + +checkUndeclaredImports(); + +if (failures.length > 0) { + console.error('\n❌ peerDependencies validation failed:\n'); + failures.forEach((failure) => console.error(` - ${failure}`)); + console.error( + '\nThese manifests resolve under Yarn but break `npm install` for consumers.\n' + + 'See tools/check-peer-deps/index.ts for what each rule protects against.\n' + ); + process.exit(1); +} + +console.log(`✅ peerDependencies are valid for: ${publishedPackages.map((pkg) => pkg.name).join(', ')}`); diff --git a/tools/check-peer-deps/tsconfig.json b/tools/check-peer-deps/tsconfig.json new file mode 100644 index 0000000000..2d4a3a84ec --- /dev/null +++ b/tools/check-peer-deps/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "strict": true, + "target": "es2020", + "module": "commonjs", + "esModuleInterop": true, + "resolveJsonModule": true, + "noEmit": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["**/*.ts"] +} diff --git a/yarn.lock b/yarn.lock index 3d379e47d3..702aeeb150 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7118,6 +7118,13 @@ __metadata: languageName: node linkType: hard +"@types/semver@npm:^7.7.1": + version: 7.8.0 + resolution: "@types/semver@npm:7.8.0" + checksum: 10c0/46a1f38e36013448b279798e8b134e7e8de3b3630b1f40683903b9d57d36dd1d70838515570ed9a149fa09e9c381b9e39319b58d7b9f3b929b42ae2becff7aa3 + languageName: node + linkType: hard + "@types/send@npm:*, @types/send@npm:<1": version: 0.17.6 resolution: "@types/send@npm:0.17.6" @@ -13617,6 +13624,7 @@ __metadata: "@types/merge2": "npm:^0.3.30" "@types/node": "npm:^24.10.4" "@types/nunjucks": "npm:^3.2.1" + "@types/semver": "npm:^7.7.1" "@types/spdx-satisfies": "npm:^0.1.2" ag-grid-angular: "npm:^34.3.1" ag-grid-community: "npm:^34.3.1" @@ -13670,6 +13678,7 @@ __metadata: rollup-plugin-typescript2: "npm:^0.37.0" rxjs: "npm:^7.8.2" sass: "npm:^1.93.3" + semver: "npm:^7.8.1" spdx-satisfies: "npm:^5.0.1" style-dictionary: "npm:^3.7.1" stylelint: "npm:^17.14.1" @@ -16884,7 +16893,7 @@ __metadata: languageName: node linkType: hard -"semver@npm:^7.5.2, semver@npm:^7.8.5": +"semver@npm:^7.5.2, semver@npm:^7.8.1, semver@npm:^7.8.5": version: 7.8.5 resolution: "semver@npm:7.8.5" bin: From c49e12956791042c6cb6ca0855e03f6a65bdf76a Mon Sep 17 00:00:00 2001 From: lskramarov Date: Wed, 5 Aug 2026 20:45:17 +0300 Subject: [PATCH 2/2] fix: after review --- docs/guides/installation.en.md | 2 +- docs/guides/installation.ru.md | 2 +- packages/docs-examples/package.json | 5 +- packages/schematics/rollup.config.js | 3 +- .../ng-add/__snapshots__/ng-add.spec.ts.snap | 1 + packages/schematics/src/ng-add/index.ts | 20 +++++- packages/schematics/src/ng-add/ng-add.spec.ts | 13 ++++ tools/builders/packager/build.ts | 7 +- tools/check-npm-resolution/index.ts | 17 +++-- tools/check-peer-deps/index.ts | 65 +++++++++++++++++-- 10 files changed, 117 insertions(+), 18 deletions(-) diff --git a/docs/guides/installation.en.md b/docs/guides/installation.en.md index 7a18ae8ab9..7be489a3a5 100644 --- a/docs/guides/installation.en.md +++ b/docs/guides/installation.en.md @@ -33,7 +33,7 @@ bootstrapApplication(AppComponent, { ``` Without this provider, opening a component that animates — dropdown, select, tooltip, toast, -datepicker — fails with `NG05105: Found the synthetic property @state`. +datepicker — fails with `NG05105: Unexpected synthetic property @state found`. ### Setting up styles diff --git a/docs/guides/installation.ru.md b/docs/guides/installation.ru.md index d20a754011..788c38484c 100644 --- a/docs/guides/installation.ru.md +++ b/docs/guides/installation.ru.md @@ -33,7 +33,7 @@ bootstrapApplication(AppComponent, { ``` Без этого провайдера открытие компонента с анимацией — dropdown, select, tooltip, toast, -datepicker — завершится ошибкой `NG05105: Found the synthetic property @state`. +datepicker — завершится ошибкой `NG05105: Unexpected synthetic property @state found`. ### Настройка стилей diff --git a/packages/docs-examples/package.json b/packages/docs-examples/package.json index 6024e1af52..fa035cdc87 100644 --- a/packages/docs-examples/package.json +++ b/packages/docs-examples/package.json @@ -14,9 +14,12 @@ "@angular/core": "{{NG_VERSION}}", "@angular/common": "{{NG_VERSION}}", "@angular/forms": "{{NG_VERSION}}", + "@angular/platform-browser": "{{NG_VERSION}}", + "@angular/router": "{{NG_VERSION}}", "@koobiq/components": "^{{VERSION}}", "@koobiq/angular-moment-adapter": "^{{VERSION}}", - "@koobiq/angular-luxon-adapter": "^{{VERSION}}" + "@koobiq/angular-luxon-adapter": "^{{VERSION}}", + "highlight.js": "^11.11.1" }, "dependencies": { "tslib": "^2.6.2" diff --git a/packages/schematics/rollup.config.js b/packages/schematics/rollup.config.js index faa314bdb1..148b2789b4 100644 --- a/packages/schematics/rollup.config.js +++ b/packages/schematics/rollup.config.js @@ -63,7 +63,8 @@ module.exports = [ 'VERSIONS.KOOBIQ_DATE_ADAPTER': version(pkg.dependencies['@koobiq/date-adapter']), 'VERSIONS.KOOBIQ_DESIGN_TOKENS': version(pkg.devDependencies['@koobiq/design-tokens']), 'VERSIONS.KOOBIQ_ICONS': version(pkg.dependencies['@koobiq/icons']), - 'VERSIONS.LUXON': version(pkg.devDependencies.luxon) + 'VERSIONS.LUXON': version(pkg.devDependencies.luxon), + 'VERSIONS.OVERLAYSCROLLBARS': version(pkg.dependencies.overlayscrollbars) }), typescript({ tsconfig: path.join(__dirname, 'tsconfig.rollup.json') diff --git a/packages/schematics/src/ng-add/__snapshots__/ng-add.spec.ts.snap b/packages/schematics/src/ng-add/__snapshots__/ng-add.spec.ts.snap index ffb061c534..c9573c34a6 100644 --- a/packages/schematics/src/ng-add/__snapshots__/ng-add.spec.ts.snap +++ b/packages/schematics/src/ng-add/__snapshots__/ng-add.spec.ts.snap @@ -16,6 +16,7 @@ exports[`ng add '@koobiq/components' should add missing dependencies to 'package "@koobiq/design-tokens": "^0.0.0", "@koobiq/icons": "^0.0.0", "luxon": "^0.0.0", + "overlayscrollbars": "^0.0.0", "rxjs": "~7.8.0", "tslib": "^2.3.0", "zone.js": "~0.15.0", diff --git a/packages/schematics/src/ng-add/index.ts b/packages/schematics/src/ng-add/index.ts index 48f403a130..3e10aeb76a 100644 --- a/packages/schematics/src/ng-add/index.ts +++ b/packages/schematics/src/ng-add/index.ts @@ -1,5 +1,6 @@ -import { Rule, SchematicsException, Tree } from '@angular-devkit/schematics'; +import { Rule, SchematicContext, SchematicsException, Tree } from '@angular-devkit/schematics'; import { readWorkspace } from '@schematics/angular/utility'; +import { logMessage } from '../utils/messages'; import { addPackageToPackageJson } from '../utils/package-config'; import * as messages from './messages'; import { Schema } from './schema'; @@ -12,7 +13,8 @@ const VERSIONS = { KOOBIQ_DATE_ADAPTER: '^0.0.0', KOOBIQ_ICONS: '^0.0.0', KOOBIQ_DESIGN_TOKENS: '^0.0.0', - LUXON: '^0.0.0' + LUXON: '^0.0.0', + OVERLAYSCROLLBARS: '^0.0.0' }; /** @@ -20,7 +22,7 @@ const VERSIONS = { * It installs all dependencies in the 'package.json' and runs 'ng-add-setup-project' schematic. */ export default function ngAdd(options: Schema): Rule { - return async (tree: Tree) => { + return async (tree: Tree, context: SchematicContext) => { const { project } = options; if (project) { @@ -43,5 +45,17 @@ export default function ngAdd(options: Schema): Rule { addPackageToPackageJson(tree, '@koobiq/icons', VERSIONS.KOOBIQ_ICONS); addPackageToPackageJson(tree, '@koobiq/design-tokens', VERSIONS.KOOBIQ_DESIGN_TOKENS); addPackageToPackageJson(tree, 'luxon', VERSIONS.LUXON); + // `overlayscrollbars` is a mandatory peer too: `@koobiq/components/scrollbar` imports it + // unconditionally, and content-panel, notification-center and app-switcher all pull that in. + addPackageToPackageJson(tree, 'overlayscrollbars', VERSIONS.OVERLAYSCROLLBARS); + + // Installing `@angular/animations` only satisfies the peer; the application still has to + // provide the animations module itself, so point at the one step this schematic cannot do. + logMessage(context.logger, [ + 'Angular animations have to be provided by the application.', + "Add `provideAnimations()` from '@angular/platform-browser/animations' to the providers", + 'of `bootstrapApplication`, otherwise every component that animates (dropdown, select,', + 'tooltip, toast, datepicker) fails with NG05105 as soon as it opens.' + ]); }; } diff --git a/packages/schematics/src/ng-add/ng-add.spec.ts b/packages/schematics/src/ng-add/ng-add.spec.ts index 7060acc042..d02d05b1ba 100644 --- a/packages/schematics/src/ng-add/ng-add.spec.ts +++ b/packages/schematics/src/ng-add/ng-add.spec.ts @@ -22,6 +22,19 @@ describe(`ng add '@koobiq/components'`, () => { expect(getPackageJsonDependencies(tree)).toMatchSnapshot('after running schematics'); }); + it(`should add every mandatory peer of '@koobiq/components'`, async () => { + const tree = await runner.runSchematic('ng-add', {}, appTree); + const dependencies = getPackageJsonDependencies(tree); + + // A mandatory peer that `ng add` skips leaves the consumer with a package.json that never + // recorded it, so the app only breaks later, at bundle time, with "Module not found". + // The versions themselves are injected by rollup at build time, so they read as the + // `^0.0.0` source default here — only the presence of the entry is meaningful. + ['@angular/animations', '@angular/cdk', 'overlayscrollbars'].forEach((dependency) => { + expect(dependencies[dependency]).toBeDefined(); + }); + }); + it(`should report when specified 'project' is not found`, async () => { await expect(runner.runSchematic('ng-add', { project: 'test' }, appTree)).rejects.toThrow( "Unable to find project 'test' in the workspace" diff --git a/tools/builders/packager/build.ts b/tools/builders/packager/build.ts index 30be062c42..a5415e1531 100644 --- a/tools/builders/packager/build.ts +++ b/tools/builders/packager/build.ts @@ -147,7 +147,9 @@ function syncComponentsVersion( if (rootPackageJson.version && (!newPackageJson.version || newPackageJson.version.trim() === placeholder)) { newPackageJson.version = rootPackageJson.version; - for (const [key, value] of Object.entries(releaseJson.peerDependencies!)) { + // A package without `peerDependencies` is nothing to sync, not a crash: reading the field + // unguarded would throw a raw TypeError before `assertNoPlaceholders` could report anything. + for (const [key, value] of Object.entries(releaseJson.peerDependencies || {})) { if (value.includes(placeholder)) { // Substitute rather than overwrite, so the source manifest controls the shape of the // range: `^{{VERSION}}` must publish as `^1.2.3`, not as the exact pin `1.2.3`. @@ -156,6 +158,7 @@ function syncComponentsVersion( const range = value.replace(placeholder, newPackageJson.version!); context.logger.info(`${key}: ${range}`); + // Reaching this line means the field exists — the loop body never runs otherwise. newPackageJson.peerDependencies![key] = range; } } @@ -172,7 +175,7 @@ function syncNgVersion( ): IPackageJson { const updatedJson = { ...releaseJson }; - for (const [key, value] of Object.entries(releaseJson.peerDependencies!)) { + for (const [key, value] of Object.entries(releaseJson.peerDependencies || {})) { if (value.includes(placeholder)) { const range = value.replace(placeholder, rootPackageJson.requiredAngularVersion); diff --git a/tools/check-npm-resolution/index.ts b/tools/check-npm-resolution/index.ts index 15a87ab6a7..dc730e9290 100644 --- a/tools/check-npm-resolution/index.ts +++ b/tools/check-npm-resolution/index.ts @@ -29,6 +29,11 @@ const rootPackageJson: PackageJson = JSON.parse(readFileSync(join(projectRoot, ' const angularVersion = rootPackageJson.dependencies!['@angular/core']; const cdkVersion = rootPackageJson.dependencies!['@angular/cdk']; +/** Mirrors `version()` in packages/schematics/rollup.config.js, which shapes what `ng add` writes. */ +const caret = (range: string): string => (range.startsWith('^') ? range : `^${range}`); + +const iconsRange = caret(rootPackageJson.dependencies!['@koobiq/icons']); + /** An Angular app of the given major, as `ng new` would leave it. */ const angularApp = (versions: Record) => ({ name: 'fixture', @@ -55,10 +60,13 @@ const fixtures = [ extraInstalls: [] as string[] }, { - name: 'angular-20-app-latest-icons', - description: 'an Angular 20 application pulling the latest @koobiq/icons, as `ng add` does', + name: 'angular-20-app-ng-add-icons', + description: 'an Angular 20 application with the @koobiq/icons range `ng add` installs', packageJson: angularApp({ angular: angularVersion, cdk: cdkVersion }), - extraInstalls: ['@koobiq/icons@latest'] + // The range comes from the root manifest, the way the schematic resolves it at build time — + // NOT from `@latest`. Pinning to the registry tip would make an unrelated @koobiq/icons + // release fail this check on every open pull request, for a version nothing has adopted yet. + extraInstalls: [`@koobiq/icons@${iconsRange}`] }, { name: 'documented-install', @@ -82,7 +90,8 @@ const npm = (args: string[], cwd: string): string => { // Node >= 20.12 rejects spawning a `.cmd` without a shell, so Windows goes through `execSync`. // Paths here come from `mkdtemp` and the workspace, both of which can contain spaces — quote - // every argument. + // every argument. Quoting is enough on its own: `"` is a reserved character in Windows paths, so + // no argument can close the quote, and cmd.exe leaves `&`/`|`/`^` alone inside one. return isWindows ? execSync(`npm.cmd ${args.map((arg) => `"${arg}"`).join(' ')}`, { cwd, encoding, stdio }) : execFileSync('npm', args, { cwd, encoding, stdio }); diff --git a/tools/check-peer-deps/index.ts b/tools/check-peer-deps/index.ts index 36b4f7ca41..fc8b7404d0 100644 --- a/tools/check-peer-deps/index.ts +++ b/tools/check-peer-deps/index.ts @@ -54,6 +54,9 @@ const resolvePlaceholders = (range: string): string => */ const caret = (range: string): string => (range.startsWith('^') ? range : `^${range}`); +const ngAddPath = join(projectRoot, 'packages', 'schematics', 'src', 'ng-add', 'index.ts'); +const rollupConfigPath = join(projectRoot, 'packages', 'schematics', 'rollup.config.js'); + const schematicInjectedRanges: Record = { '@angular/animations': caret(rootPackageJson.dependencies!['@angular/animations']), '@angular/cdk': caret(rootPackageJson.dependencies!['@angular/cdk']), @@ -61,7 +64,9 @@ const schematicInjectedRanges: Record = { '@koobiq/date-formatter': caret(rootPackageJson.dependencies!['@koobiq/date-formatter']), '@koobiq/date-adapter': caret(rootPackageJson.dependencies!['@koobiq/date-adapter']), '@koobiq/icons': caret(rootPackageJson.dependencies!['@koobiq/icons']), - '@koobiq/design-tokens': caret(rootPackageJson.devDependencies!['@koobiq/design-tokens']) + '@koobiq/design-tokens': caret(rootPackageJson.devDependencies!['@koobiq/design-tokens']), + luxon: caret(rootPackageJson.devDependencies!.luxon), + overlayscrollbars: caret(rootPackageJson.dependencies!.overlayscrollbars) }; const failures: string[] = []; @@ -107,6 +112,47 @@ for (const pkg of publishedPackages) { } } +/** + * `schematicInjectedRanges` mirrors the schematic by hand, so a package added to `ng add` without a + * matching entry here would silently drop out of the range check above — losing coverage exactly + * where a new dependency needs it most. Read the schematic back and fail on the drift instead. + */ +const checkSchematicRangesInSync = () => { + const ngAdd = readFileSync(ngAddPath, { encoding: 'utf-8' }); + const installed = new Set( + [...ngAdd.matchAll(/addPackageToPackageJson\(\s*tree,\s*'([^']+)'/g)].map((match) => match[1]) + ); + + for (const dependency of installed) { + if (!(dependency in schematicInjectedRanges)) { + fail('schematics', `\`ng add\` installs "${dependency}", which schematicInjectedRanges does not list`); + } + } + + for (const dependency of Object.keys(schematicInjectedRanges)) { + if (!installed.has(dependency)) { + fail('schematics', `schematicInjectedRanges lists "${dependency}", which \`ng add\` does not install`); + } + } + + // Every `VERSIONS.*` the schematic reads is a build-time string replacement. One missing from + // rollup's map is not an error anywhere — it just publishes the literal "VERSIONS.FOO" as the + // installed range, and the schematic's own tests read the source default, so they never see it. + const rollupConfig = readFileSync(rollupConfigPath, { encoding: 'utf-8' }); + const referenced = new Set([...ngAdd.matchAll(/VERSIONS\.([A-Z_]+)/g)].map((match) => match[1])); + + for (const key of referenced) { + if (!rollupConfig.includes(`'VERSIONS.${key}'`)) { + fail( + 'schematics', + `\`ng add\` reads VERSIONS.${key}, which packages/schematics/rollup.config.js never replaces` + ); + } + } +}; + +checkSchematicRangesInSync(); + /** Every module the published bundles import but do not declare is a "Module not found" for consumers. */ const checkUndeclaredImports = () => { const components = publishedPackages.find((pkg) => pkg.name === 'components'); @@ -126,7 +172,11 @@ const checkUndeclaredImports = () => { [ 'grep', '-hoE', - "from '(@?[^.'][^']*)'", + // Static `import`/`export ... from '…'` plus lazy `import('…')`: a dynamically + // imported package still has to be installed for the consumer's bundler to find it. + // The space after `from` is required — without it prose like `the 'from' date-time` + // in a comment parses as an import of whatever the next quoted run happens to be. + "(from +|import\\( *)'(@?[^.'][^']*)'", '--', 'packages/components/**/*.ts', // Tests and dev harnesses are not published, so their imports carry no obligation. @@ -136,15 +186,20 @@ const checkUndeclaredImports = () => { ], { cwd: projectRoot, encoding: 'utf-8' } ); - } catch { - // `git grep` exits non-zero when nothing matches; nothing to validate in that case. + } catch (error) { + // Exit code 1 is `git grep`'s "no matches"; anything else means the search never ran, and + // silently returning would leave this — the only check for undeclared imports — green. + if ((error as { status?: number }).status !== 1) { + throw error; + } + return; } const undeclared = new Set(); for (const line of imports.split('\n')) { - const match = line.match(/from '([^']+)'/); + const match = line.match(/(?:from +|import\( *)'([^']+)'/); if (!match) continue;