From 6891e4e02d9a832cc39786f5983e84b1315e5ee0 Mon Sep 17 00:00:00 2001 From: fureev Date: Fri, 28 Aug 2026 15:58:44 +0300 Subject: [PATCH] =?UTF-8?q?feat(unplugin):=20=D0=B3=D0=B5=D0=B9=D1=82=20?= =?UTF-8?q?=D0=B4=D1=80=D0=B5=D0=B9=D1=84=D0=B0=20=D0=BC=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D1=84=D0=B5=D1=81=D1=82=D0=B0=20=D0=BF=D1=80=D0=BE=D1=82=D0=B8?= =?UTF-8?q?=D0=B2=20exports=20=D1=8F=D0=B4=D1=80=D0=B0;=20=D1=81=D0=B2?= =?UTF-8?q?=D0=BE=D0=B9=20=D0=BB=D0=B8=D0=BD=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Whitelist резолвера ведётся руками — ради детерминизма и нулевого I/O, и это верно. Плата в том, что расхождение с ядром ничем не наказывалось: собственные тесты сверяют вывод резолвера с теми же литералами, какие в него зашиты, и остаются зелёными, когда директиву переименовали, а компонентный subpath перестал существовать. У компонентных пакетов эту роль играет `granular doctor`; провайдера у резолвера нет, и доктору тут проверять нечего. Гейт читает `exports` ядра, а не исходники: резолвер отдаёт потребителю строку импорта, и живёт она ровно пока такой ключ объявлен. Проверка идёт в обе стороны — каждая директива whitelist'а указывает на живой subpath и правда экспортирует своё имя, каждая `./directives/*` ядра резолверу известна (`globalDirectives` исключён как агрегат), — плюс все 98 компонентных subpath резолвятся обратно в себя. Проверено мутацией: переименование модуля в манифесте роняет три теста, потеря директивы — один. Отдельным утверждением закреплён довод докблока: ядро по-прежнему не публикует `components//styles.css`, ради чего `importStyle` и выключен по умолчанию. Начнёт публиковать — умолчание пересмотрят, а не оставят по инерции. Заодно закрыта вторая дыра: пакет был единственным в монорепо без `lint` и без `eslint.config.js`, его исходники не линтовались никогда. Конфиг по образцу `granularity-datasource` (Node и TypeScript, без Vue); `vitest.config.ts` добавлен в `tsconfig#include`, без него типизированные правила его не разбирают. Первый прогон дал восемь находок, все починены. Линт подключён в корневой `lint` и в CI-джобу пакета. `granularity-datasource` осмотрен и оставлен как есть: ни CSS, ни имён классов, ни `--gr-*`, ноль зависимостей, а набор гейтов у него уже полный. --- .github/workflows/ci.yml | 3 + package.json | 3 +- packages/unplugin-granularity/CHANGELOG.md | 29 +++++ packages/unplugin-granularity/README.md | 6 +- .../unplugin-granularity/eslint.config.js | 76 +++++++++++++ packages/unplugin-granularity/package.json | 11 +- .../src/__tests__/manifestAgainstCore.test.ts | 104 ++++++++++++++++++ packages/unplugin-granularity/src/index.ts | 2 +- packages/unplugin-granularity/tsconfig.json | 14 ++- 9 files changed, 237 insertions(+), 11 deletions(-) create mode 100644 packages/unplugin-granularity/eslint.config.js create mode 100644 packages/unplugin-granularity/src/__tests__/manifestAgainstCore.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 190b7808..df4e1f72 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1575,6 +1575,9 @@ jobs: name: granularity-dist path: packages/granularity/dist + - name: Lint package + run: yarn lint:unplugin + - name: Typecheck package run: yarn typecheck:unplugin diff --git a/package.json b/package.json index 8de52067..30949298 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "docs:check": "node scripts/generate-component-map.mjs --check", "docs:audit": "node scripts/docs-audit.mjs --report", "docs:audit:check": "node scripts/docs-audit.mjs --check", - "lint": "yarn lint:test-kit && yarn lint:granularity && yarn lint:chrono && yarn lint:charts && yarn lint:dashboard && yarn lint:forms-schema && yarn lint:datasource && yarn lint:editor && yarn lint:media && yarn lint:devtools && yarn lint:showcase", + "lint": "yarn lint:test-kit && yarn lint:granularity && yarn lint:chrono && yarn lint:charts && yarn lint:dashboard && yarn lint:forms-schema && yarn lint:datasource && yarn lint:editor && yarn lint:media && yarn lint:devtools && yarn lint:unplugin && yarn lint:showcase", "check:ranges": "node scripts/check-workspace-ranges.mjs", "check:licenses": "node scripts/check-license-copies.mjs", "lint:test-kit": "yarn workspace @feugene/granularity-test-kit lint", @@ -56,6 +56,7 @@ "lint:editor": "yarn workspace @feugene/granularity-editor lint", "lint:media": "yarn workspace @feugene/granularity-media lint", "lint:devtools": "yarn workspace @feugene/granularity-devtools lint", + "lint:unplugin": "yarn workspace @feugene/unplugin-granularity lint", "lint:showcase": "yarn workspace @feugene/granularity-showcase lint", "typecheck": "yarn typecheck:test-kit && yarn typecheck:granularity && yarn typecheck:chrono && yarn typecheck:charts && yarn typecheck:dashboard && yarn typecheck:forms-schema && yarn typecheck:datasource && yarn typecheck:editor && yarn typecheck:media && yarn typecheck:devtools && yarn typecheck:showcase && yarn typecheck:unplugin", "typecheck:test-kit": "yarn workspace @feugene/granularity-test-kit typecheck", diff --git a/packages/unplugin-granularity/CHANGELOG.md b/packages/unplugin-granularity/CHANGELOG.md index 8f980484..83b7fa1a 100644 --- a/packages/unplugin-granularity/CHANGELOG.md +++ b/packages/unplugin-granularity/CHANGELOG.md @@ -7,6 +7,35 @@ to [Semantic Versioning](https://semver.org/). ## [Unreleased] +## [v0.7.1] 2026-08-28 + +Internal only: the resolver's behaviour and its public surface are unchanged. + +### Added + +- **Drift gate: the whitelist is checked against what the core actually ships.** The manifest is maintained by + hand on purpose — that keeps the resolver deterministic and free of I/O — but nothing punished it for drifting. + The package's own tests compare the resolver's output against the same literals that are baked into it, so they + stay green when a directive is renamed in the core or a component subpath stops existing. Component packages get + that from `granular doctor`; the resolver has no granular provider, so the doctor has nothing to inspect here. + + The gate reads the core's `package.json#exports`, not its sources: what the resolver hands a consumer is an + import string, and it lives exactly as long as that key is published. It checks both directions — every + whitelisted directive points at a live subpath and really exports its named binding, and every `./directives/*` + the core publishes is known to the resolver (`globalDirectives` excepted as an aggregate) — plus that every + component subpath the core exports resolves back into itself. Verified by mutation: renaming a module in the + manifest reddens three tests, dropping a directive reddens one. + + One more assertion keeps a docblock honest: the core still publishes no `components//styles.css`, which + is the whole reason `importStyle` defaults to `false`. Should that change, the default is to be revisited + rather than left running on inertia. + +- **`lint` script and an ESLint config.** This was the only package in the monorepo without either, so its + sources were never linted — in CI or locally. The config matches `granularity-datasource` (Node + TypeScript, + no Vue); `vitest.config.ts` joined `tsconfig.json#include`, without which typed rules cannot parse it. The + first run found eight problems, all fixed. The root `lint` alias and the `build-unplugin-granularity` CI job + run it now. + ## [v0.7.0] 2026-08-27 ### Changed diff --git a/packages/unplugin-granularity/README.md b/packages/unplugin-granularity/README.md index cb69877e..93640263 100644 --- a/packages/unplugin-granularity/README.md +++ b/packages/unplugin-granularity/README.md @@ -68,10 +68,10 @@ import '@feugene/granularity/components/GrInput/styles.css' ```ts GranularityResolver({ - prefix: 'Gr', // префикс компонентов; default 'Gr' + prefix: 'Gr', // префикс компонентов; default 'Gr' importStyle: false, // подтягивать /components//styles.css; default false - directives: true, // авто-импорт директив (v-hotkey и т.п.); default true - exclude: /^GrIn/, // игнорировать имена по RegExp + directives: true, // авто-импорт директив (v-hotkey и т.п.); default true + exclude: /^GrIn/, // игнорировать имена по RegExp }) ``` diff --git a/packages/unplugin-granularity/eslint.config.js b/packages/unplugin-granularity/eslint.config.js new file mode 100644 index 00000000..019b1beb --- /dev/null +++ b/packages/unplugin-granularity/eslint.config.js @@ -0,0 +1,76 @@ +import { fileURLToPath } from 'node:url' + +import antfu from '@antfu/eslint-config' +import globals from 'globals' + +/** + * Путь до `tsconfig.json` — абсолютный, а не относительный. + * + * Типизированные правила резолвят его от **cwd**, а не от этого файла. Из + * директории пакета всё сходится, но редактор запускает ESLint из корня + * монорепо — и там `tsconfig.json` другой (точнее, его нет вовсе): каждый файл + * пакета падал с `Parsing error: Could not read Project Service default + * project`. Гейт при этом оставался зелёным, потому что `yarn lint` идёт из + * пакета, — расходились ровно IDE и CI. + */ +const tsconfigPath = fileURLToPath(new URL('./tsconfig.json', import.meta.url)) + +export default antfu( + { + ignores: [ + 'dist/**', + 'node_modules/**', + 'coverage/**', + ], + vue: false, + typescript: { + tsconfigPath, + }, + jsonc: false, + yaml: false, + unocss: false, + stylistic: true, + }, + { + rules: { + 'perfectionist/sort-imports': 'off', + 'perfectionist/sort-named-imports': 'off', + 'perfectionist/sort-exports': 'off', + + 'import/first': 'off', + 'import/consistent-type-specifier-style': 'off', + + 'test/prefer-lowercase-title': 'off', + 'node/prefer-global/process': 'off', + + 'ts/consistent-type-definitions': 'off', + 'ts/strict-boolean-expressions': 'off', + + 'no-console': 'off', + + // `break` — часть синтаксиса `case`, а не второй оператор строки: + // таблица `case 'x': doIt(); break` читается строкой на вариант. + 'style/max-statements-per-line': ['error', { max: 1, ignoredNodes: ['BreakStatement'] }], + + 'unused-imports/no-unused-imports': 'warn', + }, + languageOptions: { + sourceType: 'module', + globals: { + ...globals.node, + }, + }, + }, + { + // Примеры в README — не программа: `eslint --fix` вычищает из них импорты + // как неиспользуемые переменные и оставляет пустые блоки кода. Override + // обязан идти последним: в плоском конфиге выигрывает поздний. + files: ['**/*.md', '**/*.md/**'], + rules: { + 'unused-imports/no-unused-imports': 'off', + 'unused-imports/no-unused-vars': 'off', + 'no-unused-vars': 'off', + 'ts/no-unused-vars': 'off', + }, + }, +) diff --git a/packages/unplugin-granularity/package.json b/packages/unplugin-granularity/package.json index 2d622d32..e3a4310c 100644 --- a/packages/unplugin-granularity/package.json +++ b/packages/unplugin-granularity/package.json @@ -1,7 +1,7 @@ { "name": "@feugene/unplugin-granularity", "description": "unplugin-vue-components resolver for @feugene/granularity — granular auto-import for components and directives.", - "version": "0.7.0", + "version": "0.7.1", "license": "SEE LICENSE IN LICENSE", "author": { "name": "Evgeniy Fureev", @@ -54,17 +54,22 @@ "unplugin-vue-components": ">=0.26.0" }, "devDependencies": { + "@antfu/eslint-config": "^9.3.0", "@feugene/granularity": "^0.38.0", "@types/node": "^25.9.5", + "eslint": "^10.9.1", + "globals": "^16.5.0", "typescript": "^6.0.3", "unplugin-vue-components": "^32.1.0", "vite": "^8.2.2", - "vue-tsc": "^3.3.11", - "vitest": "^4.1.11" + "vitest": "^4.1.11", + "vue-tsc": "^3.3.11" }, "scripts": { "build": "vite build && vue-tsc -p tsconfig.build.json", "dev": "vite build --watch", + "lint": "eslint . --cache", + "lint:fix": "eslint . --cache --fix", "typecheck": "vue-tsc --noEmit -p tsconfig.json", "test": "vitest --config vitest.config.ts", "test:run": "vitest run --config vitest.config.ts" diff --git a/packages/unplugin-granularity/src/__tests__/manifestAgainstCore.test.ts b/packages/unplugin-granularity/src/__tests__/manifestAgainstCore.test.ts new file mode 100644 index 00000000..90a2f56a --- /dev/null +++ b/packages/unplugin-granularity/src/__tests__/manifestAgainstCore.test.ts @@ -0,0 +1,104 @@ +import type { ComponentResolverObject } from 'unplugin-vue-components/types' +import { createRequire } from 'node:module' +import { describe, expect, it } from 'vitest' + +import { GRANULARITY_DIRECTIVES, GRANULARITY_PACKAGE_NAME } from '../manifest' +import { createGranularResolver } from '../resolver' + +/** + * Гейт дрейфа: whitelist резолвера против того, что ядро правда отгружает. + * + * Манифест держится руками — так решено ради детерминизма и нулевого I/O в + * рантайме резолвера, и это верно. Плата за решение в том, что расхождение с + * ядром ничем не наказывается: собственные тесты пакета сверяют вывод резолвера + * с такими же литералами, какие в него зашиты, и остаются зелёными, когда + * директиву в ядре переименовали, а компонентный subpath перестал + * существовать. У компонентных пакетов эту роль играет `granular doctor`; + * провайдера у резолвера нет, и доктору тут проверять нечего. + * + * Источник истины — `exports` ядра, а не его исходники: резолвер выдаёт + * потребителю строку импорта, и живёт она ровно до тех пор, пока такой ключ + * объявлен. Неопубликованный subpath — это `ERR_PACKAGE_PATH_NOT_EXPORTED` на + * сборке потребителя, а не у нас. + */ + +interface PackageManifest { + exports: Record +} + +const require = createRequire(import.meta.url) +const core = require('@feugene/granularity/package.json') as PackageManifest + +const CORE_KEYS = Object.keys(core.exports) + +/** + * Модули `directives/`, у которых нет своей директивы. `globalDirectives` — + * сборка «зарегистрировать всё разом»: в шаблоне такого имени не бывает, и + * резолверу оно не нужно. + */ +const NOT_A_DIRECTIVE = new Set(['globalDirectives']) + +function resolveWith(resolver: ComponentResolverObject, name: string): { from?: string } | undefined { + return (resolver.resolve as (name: string) => { from?: string } | undefined)(name) +} + +const coreDirectiveModules = CORE_KEYS + .filter(key => key.startsWith('./directives/')) + .map(key => key.slice('./directives/'.length)) + .filter(module => !NOT_A_DIRECTIVE.has(module)) + +const coreComponents = CORE_KEYS + .filter(key => /^\.\/components\/[A-Za-z]+$/.test(key)) + .map(key => key.slice('./components/'.length)) + +describe('манифест директив против exports ядра', () => { + it.each(Object.entries(GRANULARITY_DIRECTIVES))('%s указывает на живой subpath', (_name, descriptor) => { + expect(CORE_KEYS).toContain(`./directives/${descriptor.module}`) + }) + + it.each(Object.entries(GRANULARITY_DIRECTIVES))('%s правда экспортирует свою директиву', async (_name, descriptor) => { + const module = await import(`${GRANULARITY_PACKAGE_NAME}/directives/${descriptor.module}`) as Record + + expect(Object.keys(module)).toContain(descriptor.named) + }) + + it('ядро не завело директиву, о которой резолвер не знает', () => { + const known = new Set(Object.values(GRANULARITY_DIRECTIVES).map(descriptor => descriptor.module)) + + expect(coreDirectiveModules.filter(module => !known.has(module))).toEqual([]) + }) + + it('в ядре есть директивы — иначе гейт зелен от пустоты, а не от порядка', () => { + expect(coreDirectiveModules.length).toBeGreaterThan(0) + }) +}) + +describe('резолвинг компонентов против exports ядра', () => { + const resolver = createGranularResolver({ packageName: GRANULARITY_PACKAGE_NAME, prefix: 'Gr' }) + + it('каждый компонентный subpath ядра резолвится в него же', () => { + const broken = coreComponents + .map(name => ({ name, from: resolveWith(resolver, name)?.from })) + .filter(({ from }) => !from || !CORE_KEYS.includes(from.replace(GRANULARITY_PACKAGE_NAME, '.'))) + + expect(broken).toEqual([]) + }) + + it('компоненты в ядре есть', () => { + expect(coreComponents.length).toBeGreaterThan(0) + }) +}) + +describe('side-effect на CSS компонента', () => { + /** + * Опция `importStyle` выключена по умолчанию именно потому, что ядро таких + * subpath'ов не публикует, а включённая давала + * `ERR_PACKAGE_PATH_NOT_EXPORTED` на каждом компоненте. Гейт держит этот + * довод привязанным к факту: начнёт ядро публиковать по-компонентный CSS — + * тест упадёт, и умолчание надо будет пересмотреть, а не оставлять по + * инерции. + */ + it('ядро по-прежнему не публикует `components//styles.css`', () => { + expect(CORE_KEYS.filter(key => /^\.\/components\/[A-Za-z]+\/styles\.css$/.test(key))).toEqual([]) + }) +}) diff --git a/packages/unplugin-granularity/src/index.ts b/packages/unplugin-granularity/src/index.ts index 7f5d4772..c7691221 100644 --- a/packages/unplugin-granularity/src/index.ts +++ b/packages/unplugin-granularity/src/index.ts @@ -1,8 +1,8 @@ export { createGranularResolver, GranularityResolver, - type GranularResolverOptions, type GranularityResolverOptions, + type GranularResolverOptions, } from './resolver' export { GRANULARITY_DEFAULT_PREFIX, diff --git a/packages/unplugin-granularity/tsconfig.json b/packages/unplugin-granularity/tsconfig.json index 5d617b81..d93f375e 100644 --- a/packages/unplugin-granularity/tsconfig.json +++ b/packages/unplugin-granularity/tsconfig.json @@ -12,14 +12,22 @@ "forceConsistentCasingInFileNames": true, "allowImportingTsExtensions": true, "allowSyntheticDefaultImports": true, - "lib": ["ESNext"], + "lib": [ + "ESNext" + ], "skipLibCheck": true, "noEmit": true, - "types": ["node"] + "types": [ + "node" + ] }, "include": [ "vite.config.ts", + "vitest.config.ts", "src/**/*.ts" ], - "exclude": ["node_modules", "dist"] + "exclude": [ + "node_modules", + "dist" + ] }