Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
29 changes: 29 additions & 0 deletions packages/unplugin-granularity/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<Name>/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
Expand Down
6 changes: 3 additions & 3 deletions packages/unplugin-granularity/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,10 @@ import '@feugene/granularity/components/GrInput/styles.css'

```ts
GranularityResolver({
prefix: 'Gr', // префикс компонентов; default 'Gr'
prefix: 'Gr', // префикс компонентов; default 'Gr'
importStyle: false, // подтягивать <pkg>/components/<Name>/styles.css; default false
directives: true, // авто-импорт директив (v-hotkey и т.п.); default true
exclude: /^GrIn/, // игнорировать имена по RegExp
directives: true, // авто-импорт директив (v-hotkey и т.п.); default true
exclude: /^GrIn/, // игнорировать имена по RegExp
})
```

Expand Down
76 changes: 76 additions & 0 deletions packages/unplugin-granularity/eslint.config.js
Original file line number Diff line number Diff line change
@@ -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',
},
},
)
11 changes: 8 additions & 3 deletions packages/unplugin-granularity/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, unknown>
}

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<string, unknown>

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/<Name>/styles.css`', () => {
expect(CORE_KEYS.filter(key => /^\.\/components\/[A-Za-z]+\/styles\.css$/.test(key))).toEqual([])
})
})
2 changes: 1 addition & 1 deletion packages/unplugin-granularity/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
export {
createGranularResolver,
GranularityResolver,
type GranularResolverOptions,
type GranularityResolverOptions,
type GranularResolverOptions,
} from './resolver'
export {
GRANULARITY_DEFAULT_PREFIX,
Expand Down
14 changes: 11 additions & 3 deletions packages/unplugin-granularity/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
}
Loading