From b76a85bdccfe82b1b33e6f5064f27902adabd3b2 Mon Sep 17 00:00:00 2001 From: Andrew Ghostuhin Date: Thu, 30 Apr 2026 05:14:07 +0300 Subject: [PATCH] feat(code-schematics): restore schematic smoke helper --- code/code-schematics/package.json | 3 +- .../src/scripts/schematic-smoke.script.ts | 240 ++++++++++++++++++ docs/raijin/README.md | 3 +- docs/raijin/README.ru.md | 3 +- docs/raijin/commands.md | 2 +- docs/raijin/commands.ru.md | 2 +- docs/raijin/index.meta.v1.json | 4 +- docs/raijin/index.v1.json | 11 +- docs/raijin/packages.md | 2 +- docs/raijin/packages.ru.md | 2 +- docs/raijin/quickstart.md | 15 +- docs/raijin/quickstart.ru.md | 15 +- docs/raijin/semantics.v1.json | 4 +- package.json | 4 +- scripts/raijin/generate-artifacts.mjs | 50 +++- scripts/raijin/generate-semantics.mjs | 14 +- 16 files changed, 346 insertions(+), 28 deletions(-) create mode 100644 code/code-schematics/src/scripts/schematic-smoke.script.ts diff --git a/code/code-schematics/package.json b/code/code-schematics/package.json index 4ddf21565..c8e869ffa 100644 --- a/code/code-schematics/package.json +++ b/code/code-schematics/package.json @@ -15,7 +15,8 @@ "build:library": "yarn library build", "build:schematic-factory": "yarn node ./src/scripts/schematic-factory-build.script.ts", "prepack": "yarn run build", - "postpack": "rm -rf dist" + "postpack": "rm -rf dist", + "smoke:schematic": "yarn node ./src/scripts/schematic-smoke.script.ts" }, "dependencies": { "@angular-devkit/core": "19.1.5", diff --git a/code/code-schematics/src/scripts/schematic-smoke.script.ts b/code/code-schematics/src/scripts/schematic-smoke.script.ts new file mode 100644 index 000000000..4d3c04d2d --- /dev/null +++ b/code/code-schematics/src/scripts/schematic-smoke.script.ts @@ -0,0 +1,240 @@ +/* eslint-disable no-await-in-loop, no-console */ + +import type { PortablePath } from '@yarnpkg/fslib' + +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' + +import { prepareTmpDir } from '../helpers/index.js' +import { runSchematicHelper } from '../helpers/index.js' +import { writeTmpSchematicHelper } from '../helpers/index.js' + +const helperScanRoots = [ + 'package.json', + 'scripts/raijin', + 'README.md', + 'README_EN.md', + 'code/code-schematics/src/scripts/schematic-smoke.script.ts', + 'docs/README.md', + 'docs/README.ru.md', + 'docs/raijin/README.md', + 'docs/raijin/README.ru.md', + 'docs/raijin/quickstart.md', + 'docs/raijin/quickstart.ru.md', + 'docs/raijin/commands.md', + 'docs/raijin/commands.ru.md', +] + +const requiredGeneratedFiles = [ + '.gitignore', + '.prettierrc.mjs', + '.github/workflows/checks.yaml', + '.github/workflows/preview.yaml', + '.github/workflows/release.yaml', + 'tsconfig.json', +] + +const readJson = async (filePath: string): Promise => + JSON.parse(await fs.readFile(filePath, 'utf8')) as T + +const pathExists = async (filePath: string): Promise => { + try { + await fs.access(filePath) + return true + } catch { + return false + } +} + +const findRepoRoot = async (startDir: string): Promise => { + let currentDir = startDir + + while (currentDir !== path.dirname(currentDir)) { + if (await pathExists(path.join(currentDir, 'docs/raijin/index.v1.json'))) { + return currentDir + } + + currentDir = path.dirname(currentDir) + } + + throw new Error('Cannot find repository root with docs/raijin/index.v1.json') +} + +const walkFiles = async (entryPath: string): Promise> => { + if (!(await pathExists(entryPath))) return [] + + const stat = await fs.stat(entryPath) + + if (stat.isFile()) return [entryPath] + + const entries = await fs.readdir(entryPath, { withFileTypes: true }) + const nested = await Promise.all( + entries.map(async (entry) => walkFiles(path.join(entryPath, entry.name))) + ) + + return nested.flat() +} + +const assertInactiveCommandsAreNotInvoked = async (repoRoot: string): Promise => { + type CommandIndex = { + commands: Array<{ + command: string + status: string + }> + } + + const index = await readJson(path.join(repoRoot, 'docs/raijin/index.v1.json')) + const inactiveCommands = index.commands + .filter((command) => command.status === 'inactive') + .map((command) => command.command) + + const scanFiles = ( + await Promise.all( + helperScanRoots.map(async (scanRoot) => walkFiles(path.join(repoRoot, scanRoot))) + ) + ) + .flat() + .filter((filePath) => + ['.json', '.js', '.mjs', '.ts', '.tsx', '.md'].includes(path.extname(filePath))) + + const violations: Array = [] + + for (const filePath of scanFiles) { + const content = await fs.readFile(filePath, 'utf8') + const relativePath = path.relative(repoRoot, filePath) + + for (const command of inactiveCommands) { + const invocation = ['yarn', command].join(' ') + + if (content.includes(invocation)) { + violations.push(`${relativePath}: uses inactive command "${invocation}"`) + } + } + } + + if (violations.length > 0) { + throw new Error(`Inactive command invocations found:\n${violations.join('\n')}`) + } +} + +const writeFixturePackage = async (fixtureDir: string): Promise => { + const packageJson = { + name: 'raijin-schematic-smoke-fixture', + private: true, + type: 'module', + } + + await fs.writeFile( + path.join(fixtureDir, 'package.json'), + `${JSON.stringify(packageJson, null, 2)}\n` + ) + await fs.writeFile(path.join(fixtureDir, 'tsconfig.json'), `${JSON.stringify({}, null, 2)}\n`) +} + +const prepareCollectionDir = async (repoRoot: string, collectionDir: string): Promise => { + const previousCwd = process.cwd() + const collectionPortablePath = collectionDir as PortablePath + + try { + process.chdir(repoRoot) + await prepareTmpDir(collectionPortablePath) + } finally { + process.chdir(previousCwd) + } +} + +const runProjectSchematic = async ({ + collectionPath, + fixtureDir, +}: { + collectionPath: string + fixtureDir: string +}): Promise => { + const previousCwd = process.cwd() + + try { + process.chdir(fixtureDir) + + const exitCode = await runSchematicHelper( + 'project', + { + type: 'project', + cwd: fixtureDir, + }, + collectionPath + ) + + if (exitCode !== 0) { + throw new Error(`Schematic workflow failed with exit code ${exitCode}`) + } + } finally { + process.chdir(previousCwd) + } +} + +const assertGeneratedFixture = async (fixtureDir: string): Promise => { + const missingFiles: Array = [] + + for (const relativePath of requiredGeneratedFiles) { + if (!(await pathExists(path.join(fixtureDir, relativePath)))) { + missingFiles.push(relativePath) + } + } + + if (missingFiles.length > 0) { + throw new Error(`Schematic smoke did not generate required files:\n${missingFiles.join('\n')}`) + } + + const gitignore = await fs.readFile(path.join(fixtureDir, '.gitignore'), 'utf8') + + if (!gitignore.includes('node_modules') || !gitignore.includes('dist/')) { + throw new Error('Generated .gitignore does not contain expected baseline entries') + } + + const tsconfig = await readJson<{ compilerOptions?: unknown }>( + path.join(fixtureDir, 'tsconfig.json') + ) + + if (!tsconfig.compilerOptions || typeof tsconfig.compilerOptions !== 'object') { + throw new Error('Generated tsconfig.json does not contain compilerOptions') + } +} + +const runSchematicSmoke = async (): Promise => { + const repoRoot = await findRepoRoot(process.cwd()) + + await assertInactiveCommandsAreNotInvoked(repoRoot) + + const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'raijin-schematic-smoke-')) + const collectionDir = path.join(tmpRoot, 'collection') + const fixtureDir = path.join(tmpRoot, 'fixture') + + try { + await fs.mkdir(collectionDir, { recursive: true }) + await fs.mkdir(fixtureDir, { recursive: true }) + await writeFixturePackage(fixtureDir) + await writeTmpSchematicHelper(collectionDir as PortablePath) + await prepareCollectionDir(repoRoot, collectionDir) + await runProjectSchematic({ + collectionPath: path.join(collectionDir, 'collection.json'), + fixtureDir, + }) + await assertGeneratedFixture(fixtureDir) + } finally { + await fs.rm(tmpRoot, { recursive: true, force: true }) + } +} + +try { + await runSchematicSmoke() + console.log('Schematic smoke passed') +} catch (error) { + if (error instanceof Error) { + console.error(error.message) + } else { + console.error(error) + } + + process.exitCode = 1 +} diff --git a/docs/raijin/README.md b/docs/raijin/README.md index 710772895..5903e4e78 100644 --- a/docs/raijin/README.md +++ b/docs/raijin/README.md @@ -31,6 +31,7 @@ Navigation for custom `atls` Yarn bundle docs - `yarn raijin:generate` - `yarn raijin:check` +- `yarn schematic:test` @@ -38,4 +39,4 @@ Navigation for custom `atls` Yarn bundle docs - Commands: 36 (active: 35, inactive: 1) - Workspace packages: 72 -- Last generated: 2026-04-29T23:07:25.462Z +- Last generated: 2026-04-30T02:04:56.641Z diff --git a/docs/raijin/README.ru.md b/docs/raijin/README.ru.md index afaa97a28..8b27ace5b 100644 --- a/docs/raijin/README.ru.md +++ b/docs/raijin/README.ru.md @@ -31,6 +31,7 @@ - `yarn raijin:generate` - `yarn raijin:check` +- `yarn schematic:test` @@ -38,4 +39,4 @@ - Команд: 36 (active: 35, inactive: 1) - Workspace-пакетов: 72 -- Последняя генерация: 2026-04-29T23:07:25.462Z +- Последняя генерация: 2026-04-30T02:04:56.641Z diff --git a/docs/raijin/commands.md b/docs/raijin/commands.md index 400e47810..1d2291ae8 100644 --- a/docs/raijin/commands.md +++ b/docs/raijin/commands.md @@ -600,7 +600,7 @@ Command map extracted from `yarn/plugin-*` and `@atls/yarn-cli` bundle - Status: `inactive` - Purpose: Generate a new project from schematics templates. - When to use: Use when scaffolding a new project structure from predefined templates. -- Example: `yarn generate project` +- Example: unavailable for inactive command - Plugin: `@atls/yarn-plugin-schematics` - Source: `yarn/plugin-schematics/sources/commands/generate-project.command.tsx` - Routing: do not use (plugin is in bundle but not exported from plugin index) diff --git a/docs/raijin/commands.ru.md b/docs/raijin/commands.ru.md index 1f9a16fb1..a3e952a97 100644 --- a/docs/raijin/commands.ru.md +++ b/docs/raijin/commands.ru.md @@ -600,7 +600,7 @@ - Статус: `inactive` - Назначение: Сгенерировать новый проект из шаблонов schematics. - Когда использовать: Используйте при создании новой структуры проекта из готовых шаблонов. -- Пример: `yarn generate project` +- Пример: недоступен для inactive-команды - Плагин: `@atls/yarn-plugin-schematics` - Исходник: `yarn/plugin-schematics/sources/commands/generate-project.command.tsx` - Маршрутизация: не использовать (plugin is in bundle but not exported from plugin index) diff --git a/docs/raijin/index.meta.v1.json b/docs/raijin/index.meta.v1.json index 05ba83f6b..89c56d9e2 100644 --- a/docs/raijin/index.meta.v1.json +++ b/docs/raijin/index.meta.v1.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, "generatedBy": "scripts/raijin/generate-artifacts.mjs", - "contentSha256": "65af7f2f92cbb25b7c6ffc03f74fef03ce3b6c3ada0bb1b2e2e48bbf10b18160", + "contentSha256": "ea3a439834e83cb928e43f7904dcf691899b5e05f7a0ae3fc3087d26b15cc1b3", "packageManager": "yarn@4.14.1", "workspaceCount": 72, "commandCount": 36, @@ -10,5 +10,5 @@ "semanticsSchemaVersion": 1, "semanticsCommandCount": 36, "semanticsWorkspaceCount": 72, - "lastGenerated": "2026-04-29T23:07:25.462Z" + "lastGenerated": "2026-04-30T02:04:56.641Z" } diff --git a/docs/raijin/index.v1.json b/docs/raijin/index.v1.json index 5ea9a561c..00c81ad52 100644 --- a/docs/raijin/index.v1.json +++ b/docs/raijin/index.v1.json @@ -858,7 +858,14 @@ "description": "", "purposeEn": "Code utility library for raijin workflows", "purposeRu": "Библиотека code-утилит для raijin-сценариев", - "scripts": ["build", "build:library", "build:schematic-factory", "postpack", "prepack"], + "scripts": [ + "build", + "build:library", + "build:schematic-factory", + "postpack", + "prepack", + "smoke:schematic" + ], "dependencyCount": 4, "devDependencyCount": 8, "peerDependencyCount": 2 @@ -1482,5 +1489,5 @@ } ] }, - "lastGenerated": "2026-04-29T23:07:25.462Z" + "lastGenerated": "2026-04-30T02:04:56.641Z" } diff --git a/docs/raijin/packages.md b/docs/raijin/packages.md index 1569fd004..239a7e04a 100644 --- a/docs/raijin/packages.md +++ b/docs/raijin/packages.md @@ -626,7 +626,7 @@ Compact list: - When to use: Use when building templates, generators, or schematic factories. - Example: `Add a schematic that scaffolds a new workspace package.` - Tags: `code` -- Scripts: `build`, `build:library`, `build:schematic-factory`, `postpack`, `prepack` +- Scripts: `build`, `build:library`, `build:schematic-factory`, `postpack`, `prepack`, `smoke:schematic` - Dependencies: deps 4, devDeps 8, peerDeps 2 diff --git a/docs/raijin/packages.ru.md b/docs/raijin/packages.ru.md index 004ce490f..6438e32d0 100644 --- a/docs/raijin/packages.ru.md +++ b/docs/raijin/packages.ru.md @@ -626,7 +626,7 @@ - Когда использовать: Используйте при создании шаблонов, генераторов или фабрик схем. - Пример: `Добавить схему, которая создаёт новый workspace-пакет.` - Теги: `code` -- Скрипты: `build`, `build:library`, `build:schematic-factory`, `postpack`, `prepack` +- Скрипты: `build`, `build:library`, `build:schematic-factory`, `postpack`, `prepack`, `smoke:schematic` - Зависимости: deps 4, devDeps 8, peerDeps 2 diff --git a/docs/raijin/quickstart.md b/docs/raijin/quickstart.md index d7573c59d..5f3da9bc5 100644 --- a/docs/raijin/quickstart.md +++ b/docs/raijin/quickstart.md @@ -54,9 +54,22 @@ Expected result: - `yarn check` runs a complete validation pass without routing errors - `yarn files changed list` returns file list (or empty list if no changes) + + +## 5. Local schematics smoke check + +```bash +yarn schematic:test +``` + +Expected result: + +- Temporary fixture is created through public `@atls/code-schematics` exports +- Check fails if helper or Markdown docs invoke an inactive command + -## 5. How to use in an external project +## 6. How to use in an external project - Install once, then keep it current with `yarn set version atls` - Commit `.yarn/releases` and `.yarnrc.yml` changes together with bundle updates diff --git a/docs/raijin/quickstart.ru.md b/docs/raijin/quickstart.ru.md index 26509fcc3..030fd441a 100644 --- a/docs/raijin/quickstart.ru.md +++ b/docs/raijin/quickstart.ru.md @@ -54,9 +54,22 @@ yarn files changed list - `yarn check` завершает полный проход проверок без ошибок маршрутизации - `yarn files changed list` возвращает список файлов или пустой список, если изменений нет + + +## 5. Локальная smoke-проверка schematics + +```bash +yarn schematic:test +``` + +Ожидаемый результат: + +- Временный fixture создаётся через публичные экспорты `@atls/code-schematics` +- Проверка падает, если helper или Markdown-документация вызывают inactive-команду + -## 5. Как использовать в чужом проекте +## 6. Как использовать в чужом проекте - Подключите бандл один раз, затем поддерживайте версию через `yarn set version atls` - Коммитьте изменения `.yarn/releases` и `.yarnrc.yml` вместе с обновлением бандла diff --git a/docs/raijin/semantics.v1.json b/docs/raijin/semantics.v1.json index edd502a92..6689cabc1 100644 --- a/docs/raijin/semantics.v1.json +++ b/docs/raijin/semantics.v1.json @@ -256,8 +256,8 @@ "ru": "Используйте при создании новой структуры проекта из готовых шаблонов." }, "example": { - "en": "yarn generate project", - "ru": "yarn generate project" + "en": "unavailable while inactive", + "ru": "недоступно, пока команда inactive" } }, { diff --git a/package.json b/package.json index 7e9393dd3..1554ea8fc 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ ], "scripts": { "cli:build": "yarn workspace @atls/yarn-cli build", - "raijin:check": "yarn raijin:check:drift && yarn raijin:check:localization && yarn raijin:check:coverage && yarn raijin:smoke:deterministic", + "raijin:check": "yarn raijin:check:drift && yarn raijin:check:localization && yarn raijin:check:coverage && yarn raijin:smoke:deterministic && yarn raijin:smoke:schematic", "raijin:check:coverage": "node scripts/raijin/check-semantics-coverage.mjs", "raijin:check:drift": "node scripts/raijin/generate-artifacts.mjs && git diff --exit-code -- README.md README_EN.md docs/README.md docs/README.ru.md docs/raijin .agents && test -z \"$(git ls-files --others --exclude-standard -- README.md README_EN.md docs/README.md docs/README.ru.md docs/raijin .agents)\"", "raijin:check:localization": "node scripts/raijin/check-localization-sync.mjs", @@ -22,6 +22,8 @@ "raijin:generate:semantics": "node scripts/raijin/generate-semantics.mjs", "raijin:smoke:deterministic": "node scripts/raijin/run-deterministic-smoke.mjs", "raijin:smoke:llm": "node scripts/raijin/run-llm-smoke.mjs", + "raijin:smoke:schematic": "yarn workspace @atls/code-schematics smoke:schematic", + "schematic:test": "yarn raijin:smoke:schematic", "yarn:download": "yarn workspace @atls/yarn-plugin-pnp-patch yarn:download" }, "resolutions": { diff --git a/scripts/raijin/generate-artifacts.mjs b/scripts/raijin/generate-artifacts.mjs index de856fa74..863aa1d5d 100644 --- a/scripts/raijin/generate-artifacts.mjs +++ b/scripts/raijin/generate-artifacts.mjs @@ -369,6 +369,10 @@ const normalizeTags = (tags, fallbackTags) => { const fallbackCommandSemantics = (command) => { const enPurpose = `Runs "${command.command}" in ${command.domain} raijin domain` const ruPurpose = `Запускает "${command.command}" в raijin-домене ${command.domain}` + const example = + command.status === 'inactive' + ? { en: 'unavailable while inactive', ru: 'недоступно, пока команда inactive' } + : { en: `yarn ${command.command}`, ru: `yarn ${command.command}` } return { id: command.command, @@ -378,7 +382,7 @@ const fallbackCommandSemantics = (command) => { en: `Use when you need ${command.command} in project workflow`, ru: `Используйте, когда в рабочем потоке нужен сценарий ${command.command}`, }, - example: { en: `yarn ${command.command}`, ru: `yarn ${command.command}` }, + example, } } @@ -689,6 +693,7 @@ const renderRaijinReadme = (index, language) => { '', '- `yarn raijin:generate`', '- `yarn raijin:check`', + '- `yarn schematic:test`', '', '', '', @@ -773,8 +778,23 @@ const renderQuickstart = (language) => { ? '- `yarn files changed list` возвращает список файлов или пустой список, если изменений нет' : '- `yarn files changed list` returns file list (or empty list if no changes)', '', + '', + isRu ? '## 5. Локальная smoke-проверка schematics' : '## 5. Local schematics smoke check', + '', + '```bash', + 'yarn schematic:test', + '```', + '', + isRu ? 'Ожидаемый результат:' : 'Expected result:', + isRu + ? '- Временный fixture создаётся через публичные экспорты `@atls/code-schematics`' + : '- Temporary fixture is created through public `@atls/code-schematics` exports', + isRu + ? '- Проверка падает, если helper или Markdown-документация вызывают inactive-команду' + : '- Check fails if helper or Markdown docs invoke an inactive command', + '', '', - isRu ? '## 5. Как использовать в чужом проекте' : '## 5. How to use in an external project', + isRu ? '## 6. Как использовать в чужом проекте' : '## 6. How to use in an external project', '', isRu ? '- Подключите бандл один раз, затем поддерживайте версию через `yarn set version atls`' @@ -808,7 +828,7 @@ const groupCommandsByDomain = (commands) => { const renderCommandCard = (command, semantics, language) => { const isRu = language === 'ru' - return [ + const lines = [ ``, '', `#### \`${command.command}\``, @@ -820,12 +840,26 @@ const renderCommandCard = (command, semantics, language) => { isRu ? `- Когда использовать: ${languageField(semantics.whenToUse, 'ru')}` : `- When to use: ${languageField(semantics.whenToUse, 'en')}`, - isRu - ? `- Пример: \`${languageField(semantics.example, 'ru')}\`` - : `- Example: \`${languageField(semantics.example, 'en')}\``, - isRu ? `- Плагин: \`${command.plugin}\`` : `- Plugin: \`${command.plugin}\``, - isRu ? `- Исходник: \`${command.source}\`` : `- Source: \`${command.source}\``, ] + + if (command.status === 'active') { + lines.push( + isRu + ? `- Пример: \`${languageField(semantics.example, 'ru')}\`` + : `- Example: \`${languageField(semantics.example, 'en')}\`` + ) + } else { + lines.push( + isRu + ? '- Пример: недоступен для inactive-команды' + : '- Example: unavailable for inactive command' + ) + } + + lines.push(isRu ? `- Плагин: \`${command.plugin}\`` : `- Plugin: \`${command.plugin}\``) + lines.push(isRu ? `- Исходник: \`${command.source}\`` : `- Source: \`${command.source}\``) + + return lines } const renderCommandsDoc = (commands, semanticsLookup, language) => { diff --git a/scripts/raijin/generate-semantics.mjs b/scripts/raijin/generate-semantics.mjs index b822fc9b9..a50f1fa52 100644 --- a/scripts/raijin/generate-semantics.mjs +++ b/scripts/raijin/generate-semantics.mjs @@ -110,10 +110,16 @@ const fallbackCommandEntry = (command) => ({ en: `Use when ${command.command} is needed in project workflow`, ru: `Используйте, когда в рабочем потоке нужен сценарий ${command.command}`, }, - example: { - en: `yarn ${command.command}`, - ru: `yarn ${command.command}`, - }, + example: + command.status === 'inactive' + ? { + en: 'unavailable while inactive', + ru: 'недоступно, пока команда inactive', + } + : { + en: `yarn ${command.command}`, + ru: `yarn ${command.command}`, + }, }) const fallbackWorkspaceEntry = (workspace) => ({