chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
# workspace-plugin
|
||||
|
||||
This library was generated with [Nx](https://nx.dev).
|
||||
|
||||
## Building
|
||||
|
||||
Run `nx build workspace-plugin` to build the library.
|
||||
|
||||
## Running unit tests
|
||||
|
||||
Run `nx test workspace-plugin` to execute the unit tests via [Jest](https://jestjs.io).
|
||||
@@ -0,0 +1,29 @@
|
||||
import { baseConfig } from '../../eslint.config.mjs';
|
||||
import * as jsoncEslintParser from 'jsonc-eslint-parser';
|
||||
|
||||
export default [
|
||||
...baseConfig,
|
||||
{
|
||||
files: ['**/*.json'],
|
||||
rules: {
|
||||
'@nx/dependency-checks': [
|
||||
'error',
|
||||
{
|
||||
buildTargets: ['build-base'],
|
||||
},
|
||||
],
|
||||
},
|
||||
languageOptions: {
|
||||
parser: jsoncEslintParser,
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['./package.json', './generators.json'],
|
||||
rules: {
|
||||
'@nx/nx-plugin-checks': 'error',
|
||||
},
|
||||
languageOptions: {
|
||||
parser: jsoncEslintParser,
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"executors": {
|
||||
"copy-assets": {
|
||||
"implementation": "./src/executors/copy-assets/executor",
|
||||
"schema": "./src/executors/copy-assets/schema.json",
|
||||
"description": "Copies assets to the output directory"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"generators": {
|
||||
"remove-migrations": {
|
||||
"factory": "./src/generators/remove-migrations/generator",
|
||||
"schema": "./src/generators/remove-migrations/schema.json",
|
||||
"description": "remove-migrations generator"
|
||||
},
|
||||
"bump-maven-version": {
|
||||
"factory": "./src/generators/bump-maven-version/generator",
|
||||
"schema": "./src/generators/bump-maven-version/schema.json",
|
||||
"description": "Bump Maven plugin version and create migration"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/* eslint-disable */
|
||||
module.exports = {
|
||||
displayName: 'workspace-plugin',
|
||||
preset: '../../jest.preset.js',
|
||||
moduleFileExtensions: ['ts', 'js', 'html'],
|
||||
coverageDirectory: '../../coverage/tools/workspace-plugin',
|
||||
// Override the workspace-wide resolver that redirects @nx/* imports to
|
||||
// packages/* source; this project is intended to consume the installed
|
||||
// versions of its dependencies, not the local monorepo sources.
|
||||
resolver: undefined,
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "@nx/workspace-plugin",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"generators": "./generators.json",
|
||||
"executors": "./executors.json",
|
||||
"dependencies": {
|
||||
"@nx/conformance": "5.0.4",
|
||||
"@nx/devkit": "23.0.0-beta.22",
|
||||
"@nx/js": "23.0.0-beta.22",
|
||||
"@xmldom/xmldom": "^0.8.10",
|
||||
"semver": "catalog:",
|
||||
"shiki": "^4.0.2",
|
||||
"tinyglobby": "catalog:",
|
||||
"tslib": "catalog:typescript"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "./dist/src/index.js",
|
||||
"typings": "./dist/src/index.d.ts",
|
||||
"devDependencies": {}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "workspace-plugin",
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "tools/workspace-plugin/src",
|
||||
"projectType": "library",
|
||||
"targets": {
|
||||
"build": {}
|
||||
},
|
||||
"tags": []
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
import {
|
||||
createConformanceRule,
|
||||
type ConformanceViolation,
|
||||
} from '@nx/conformance';
|
||||
import { workspaceRoot } from '@nx/devkit';
|
||||
import { bundledLanguages } from 'shiki';
|
||||
// Common mappings for file extensions that don't match language IDs exactly
|
||||
const extensionToLang: Record<string, string> = {
|
||||
js: 'javascript',
|
||||
jsx: 'jsx',
|
||||
ts: 'typescript',
|
||||
tsx: 'tsx',
|
||||
md: 'markdown',
|
||||
sh: 'bash',
|
||||
yml: 'yaml',
|
||||
env: 'dotenv',
|
||||
};
|
||||
|
||||
export default createConformanceRule({
|
||||
name: 'codeblock-language',
|
||||
category: 'consistency',
|
||||
description:
|
||||
'Ensures that all code blocks in markdown and markdoc files have a language specified',
|
||||
implementation: async ({ tree, fileMapCache }) => {
|
||||
const docsProjectName = 'astro-docs';
|
||||
const violations: ConformanceViolation[] = [];
|
||||
|
||||
const docsFiles = fileMapCache.fileMap.projectFileMap?.[docsProjectName];
|
||||
|
||||
if (!docsFiles) {
|
||||
violations.push({
|
||||
message: `Could not find ${docsProjectName} project files. This is most likely an issue where the graph failed to create correctly.`,
|
||||
sourceProject: docsProjectName,
|
||||
});
|
||||
return {
|
||||
severity: 'high',
|
||||
details: {
|
||||
violations,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
for (const { file } of docsFiles) {
|
||||
const isMarkdoc = file.endsWith('.mdoc');
|
||||
if (!isMarkdoc) {
|
||||
// only validate markdoc files
|
||||
continue;
|
||||
}
|
||||
const content = tree.read(file, 'utf-8');
|
||||
|
||||
const fileViolations = checkCodeBlocks(content, file);
|
||||
violations.push(...fileViolations);
|
||||
}
|
||||
|
||||
return {
|
||||
severity: violations.length > 0 ? 'medium' : 'low',
|
||||
details: {
|
||||
violations,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// Get all supported language identifiers and aliases from shiki
|
||||
function getSupportedLanguages(): Set<string> {
|
||||
const supported = new Set<string>();
|
||||
|
||||
// bundledLanguages is an object with keys as language IDs
|
||||
for (const [langId, langGetter] of Object.entries(bundledLanguages)) {
|
||||
supported.add(langId);
|
||||
|
||||
// Also add aliases if they exist in the language definition
|
||||
const lang = typeof langGetter === 'function' ? langGetter() : langGetter;
|
||||
if (lang && 'aliases' in lang && Array.isArray(lang.aliases)) {
|
||||
lang.aliases.forEach((alias: string) => supported.add(alias));
|
||||
}
|
||||
}
|
||||
|
||||
return supported;
|
||||
}
|
||||
|
||||
// Suggest a language based on file extension
|
||||
function suggestLanguageFromFilename(
|
||||
filename: string,
|
||||
supportedLanguages: Set<string>
|
||||
): string {
|
||||
const extensionMatch = filename.match(/\.([a-zA-Z0-9]+)$/);
|
||||
if (!extensionMatch) {
|
||||
return 'text';
|
||||
}
|
||||
|
||||
const ext = extensionMatch[1].toLowerCase();
|
||||
|
||||
// First check our custom mappings
|
||||
if (extensionToLang[ext] && supportedLanguages.has(extensionToLang[ext])) {
|
||||
return extensionToLang[ext];
|
||||
}
|
||||
|
||||
// Then check if the extension itself is a supported language
|
||||
if (supportedLanguages.has(ext)) {
|
||||
return ext;
|
||||
}
|
||||
|
||||
// Fall back to text
|
||||
return 'text';
|
||||
}
|
||||
|
||||
function extractFilenameFromComment(line: string): string {
|
||||
if (line.startsWith('//')) {
|
||||
return line.slice(2).trim();
|
||||
}
|
||||
if (line.startsWith('#')) {
|
||||
return line.slice(1).trim();
|
||||
}
|
||||
if (line.startsWith('/*')) {
|
||||
return line.slice(2).replace('*/', '').trim();
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
function looksLikeFilename(line: string): boolean {
|
||||
if (!line) return false;
|
||||
|
||||
const isCommentWithContent =
|
||||
line.startsWith('//') || line.startsWith('#') || line.startsWith('/*');
|
||||
|
||||
const isFilePathWithExtension =
|
||||
line.includes('.') &&
|
||||
!line.startsWith('.') &&
|
||||
line.split(/\s+/)[0].includes('.');
|
||||
|
||||
return isCommentWithContent || isFilePathWithExtension;
|
||||
}
|
||||
|
||||
function checkEmptyCodeFence(
|
||||
lineNumber: number,
|
||||
nextLine: string,
|
||||
filePath: string,
|
||||
supportedLanguages: Set<string>
|
||||
): ConformanceViolation {
|
||||
if (!looksLikeFilename(nextLine)) {
|
||||
return {
|
||||
message: `Code block at line ${lineNumber} is missing a language identifier. Add a language to code block e.g. \`\`\`text for command output or \`\`\`shell for a command`,
|
||||
file: filePath,
|
||||
};
|
||||
}
|
||||
|
||||
const filename = extractFilenameFromComment(nextLine);
|
||||
const suggestedLang = suggestLanguageFromFilename(
|
||||
filename,
|
||||
supportedLanguages
|
||||
);
|
||||
|
||||
return {
|
||||
message: `Code block at line ${lineNumber} is missing a language identifier but has filename "${filename}" specified. Add a language to code block e.g. \`\`\`${suggestedLang}.`,
|
||||
file: filePath,
|
||||
};
|
||||
}
|
||||
|
||||
function checkTemplateOnlyFence(
|
||||
lineNumber: number,
|
||||
filePath: string
|
||||
): ConformanceViolation {
|
||||
return {
|
||||
message: `Code block at line ${lineNumber} has template fences but no language identifier. Add a language before the {% %} fences e.g., \`\`\`text {% ... %}`,
|
||||
file: resolveFilePathToWorkspaceRoot(filePath),
|
||||
};
|
||||
}
|
||||
|
||||
function checkTreeviewLanguage(
|
||||
lineNumber: number,
|
||||
language: string,
|
||||
filePath: string
|
||||
): ConformanceViolation | null {
|
||||
if (language === 'treeview') {
|
||||
return {
|
||||
message: `Code block at line ${lineNumber} uses 'treeview' which is not supported. Use 'text' or the {% filetree %} tag.`,
|
||||
file: resolveFilePathToWorkspaceRoot(filePath),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function checkShellFrameNone(
|
||||
lineNumber: number,
|
||||
language: string,
|
||||
afterBackticks: string,
|
||||
filePath: string
|
||||
): ConformanceViolation | null {
|
||||
if (language !== 'shell') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (afterBackticks.includes('frame="none"')) {
|
||||
return {
|
||||
message: `Code block at line ${lineNumber} uses 'shell' language with frame="none". Shell code blocks should not use frame="none".`,
|
||||
file: resolveFilePathToWorkspaceRoot(filePath),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function checkFileNameInFence(
|
||||
lineNumber: number,
|
||||
afterBackticks: string,
|
||||
filePath: string
|
||||
): ConformanceViolation | null {
|
||||
// Check if fileName attribute is present in the fence line
|
||||
const fileNameMatch = afterBackticks.match(/fileName=["']([^"']+)["']/);
|
||||
if (!fileNameMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fileName = fileNameMatch[1];
|
||||
return {
|
||||
message: `Code block at line ${lineNumber} has fileName="${fileName}" in the fence line. Move the filename to a comment on the next line instead (e.g., // ${fileName} or # ${fileName}).`,
|
||||
file: resolveFilePathToWorkspaceRoot(filePath),
|
||||
};
|
||||
}
|
||||
|
||||
function checkCodeBlocks(
|
||||
content: string,
|
||||
filePath: string
|
||||
): ConformanceViolation[] {
|
||||
const violations: ConformanceViolation[] = [];
|
||||
const lines = content.split('\n');
|
||||
const supportedLanguages = getSupportedLanguages();
|
||||
const codeFenceRegex = /^```(.*)$/;
|
||||
let inCodeBlock = false;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const match = lines[i].match(codeFenceRegex);
|
||||
if (!match) continue;
|
||||
|
||||
if (inCodeBlock) {
|
||||
inCodeBlock = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
inCodeBlock = true;
|
||||
const afterBackticks = match[1].trim();
|
||||
|
||||
if (afterBackticks === '') {
|
||||
const nextLine = i + 1 < lines.length ? lines[i + 1].trim() : '';
|
||||
const violation = checkEmptyCodeFence(
|
||||
i + 1,
|
||||
nextLine,
|
||||
filePath,
|
||||
supportedLanguages
|
||||
);
|
||||
violations.push(violation);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (afterBackticks.startsWith('{%')) {
|
||||
violations.push(checkTemplateOnlyFence(i + 1, filePath));
|
||||
continue;
|
||||
}
|
||||
|
||||
const languageMatch = afterBackticks.match(/^(\S+)/);
|
||||
if (languageMatch) {
|
||||
const treeviewViolation = checkTreeviewLanguage(
|
||||
i + 1,
|
||||
languageMatch[1],
|
||||
filePath
|
||||
);
|
||||
if (treeviewViolation) violations.push(treeviewViolation);
|
||||
|
||||
const shellFrameViolation = checkShellFrameNone(
|
||||
i + 1,
|
||||
languageMatch[1],
|
||||
afterBackticks,
|
||||
filePath
|
||||
);
|
||||
if (shellFrameViolation) violations.push(shellFrameViolation);
|
||||
|
||||
const fileNameViolation = checkFileNameInFence(
|
||||
i + 1,
|
||||
afterBackticks,
|
||||
filePath
|
||||
);
|
||||
if (fileNameViolation) violations.push(fileNameViolation);
|
||||
}
|
||||
}
|
||||
|
||||
return violations;
|
||||
}
|
||||
|
||||
/**
|
||||
* make sure file path is resolved from workspace root
|
||||
* so conformance reports are correctly grouped
|
||||
**/
|
||||
function resolveFilePathToWorkspaceRoot(filePath: string) {
|
||||
if (!filePath || !filePath.startsWith(workspaceRoot)) {
|
||||
return filePath;
|
||||
}
|
||||
|
||||
return filePath.replace(workspaceRoot + '/', '');
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { extractDocumentedVars, extractUsedVarsFromContent } from './index';
|
||||
|
||||
describe('env-vars-documented', () => {
|
||||
describe('extractDocumentedVars()', () => {
|
||||
it('extracts NX_* names from table rows', () => {
|
||||
const mdoc = [
|
||||
'## Nx environment variables',
|
||||
'',
|
||||
'| Property | Type | Description |',
|
||||
'| ------------------------ | ------- | ----------------- |',
|
||||
'| `NX_BAIL` | boolean | Stop on failure. |',
|
||||
'| `NX_DAEMON` | boolean | Disable daemon. |',
|
||||
'| `SOMETHING_ELSE` | string | Not an Nx var. |',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
const result = extractDocumentedVars(mdoc);
|
||||
|
||||
expect(Array.from(result).sort()).toEqual(['NX_BAIL', 'NX_DAEMON']);
|
||||
});
|
||||
|
||||
it('ignores NX_* mentions in prose and descriptions', () => {
|
||||
const mdoc = [
|
||||
'Setting `NX_PROSE_ONLY` in your shell will not be detected here.',
|
||||
'| `NX_REAL_ENTRY` | boolean | Some `NX_INLINE_CODE` in the description. |',
|
||||
].join('\n');
|
||||
|
||||
const result = extractDocumentedVars(mdoc);
|
||||
|
||||
expect(Array.from(result)).toEqual(['NX_REAL_ENTRY']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractUsedVarsFromContent()', () => {
|
||||
it('finds NX_* vars via process.env.X access', () => {
|
||||
const source = `
|
||||
if (process.env.NX_BAIL === 'true') return;
|
||||
const token = process.env.NX_CLOUD_ACCESS_TOKEN;
|
||||
`;
|
||||
expect(extractUsedVarsFromContent(source, 'foo.ts').sort()).toEqual([
|
||||
'NX_BAIL',
|
||||
'NX_CLOUD_ACCESS_TOKEN',
|
||||
]);
|
||||
});
|
||||
|
||||
it('finds NX_* vars via process.env["X"] and [\'X\'] access', () => {
|
||||
const source = `
|
||||
process.env["NX_DOUBLE_QUOTED"];
|
||||
process.env['NX_SINGLE_QUOTED'];
|
||||
process.env[\`NX_TEMPLATE_LITERAL\`];
|
||||
`;
|
||||
expect(extractUsedVarsFromContent(source, 'foo.ts').sort()).toEqual([
|
||||
'NX_DOUBLE_QUOTED',
|
||||
'NX_SINGLE_QUOTED',
|
||||
'NX_TEMPLATE_LITERAL',
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not match string literals outside of process.env access', () => {
|
||||
const source = `
|
||||
const msg = "set NX_FAKE to true";
|
||||
const other = 'NX_ALSO_FAKE';
|
||||
`;
|
||||
expect(extractUsedVarsFromContent(source, 'foo.ts')).toEqual([]);
|
||||
});
|
||||
|
||||
it('finds NX_* vars in Rust via env::var and std::env::var', () => {
|
||||
const source = `
|
||||
let a = env::var("NX_FIRST");
|
||||
let b = std::env::var("NX_SECOND").unwrap_or_default();
|
||||
let c = env::var_os("NX_THIRD");
|
||||
`;
|
||||
expect(extractUsedVarsFromContent(source, 'foo.rs').sort()).toEqual([
|
||||
'NX_FIRST',
|
||||
'NX_SECOND',
|
||||
'NX_THIRD',
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not flag Rust env! macro usage (compile-time)', () => {
|
||||
const source = `let build_tag = env!("NX_BUILD_TAG");`;
|
||||
expect(extractUsedVarsFromContent(source, 'foo.rs')).toEqual([]);
|
||||
});
|
||||
|
||||
it('finds NX_* vars in Rust via EnvFilter::(try_)?from_env', () => {
|
||||
const source = `
|
||||
EnvFilter::try_from_env("NX_TRY_FROM_ENV");
|
||||
EnvFilter::from_env("NX_FROM_ENV");
|
||||
`;
|
||||
expect(extractUsedVarsFromContent(source, 'foo.rs').sort()).toEqual([
|
||||
'NX_FROM_ENV',
|
||||
'NX_TRY_FROM_ENV',
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns duplicates when the same var is read more than once', () => {
|
||||
const source = `
|
||||
process.env.NX_VERBOSE_LOGGING;
|
||||
if (process.env.NX_VERBOSE_LOGGING === 'true') {}
|
||||
`;
|
||||
expect(extractUsedVarsFromContent(source, 'foo.ts')).toEqual([
|
||||
'NX_VERBOSE_LOGGING',
|
||||
'NX_VERBOSE_LOGGING',
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import {
|
||||
createConformanceRule,
|
||||
type ConformanceViolation,
|
||||
} from '@nx/conformance';
|
||||
import type { Tree } from '@nx/devkit';
|
||||
|
||||
type Options = {
|
||||
ignore?: string[];
|
||||
};
|
||||
|
||||
const DOCS_PATH =
|
||||
'astro-docs/src/content/docs/reference/environment-variables.mdoc';
|
||||
const NX_CORE_PROJECT = 'nx';
|
||||
const MAX_EXAMPLE_FILES = 3;
|
||||
|
||||
const SCANNABLE_EXT = /\.(ts|tsx|js|mjs|cjs|rs)$/;
|
||||
const TEST_FILE = /\.(spec|test)\.(ts|tsx|js)$/;
|
||||
const EXCLUDED_SEGMENT = /(__fixtures__|__snapshots__|\/files\/|\/dist\/)/;
|
||||
|
||||
const TS_JS_USAGE = /process\.env(?:\.|\[['"`])(NX_[A-Z0-9_]+)/g;
|
||||
const RUST_ENV_VAR = /(?:std::)?env::var(?:_os)?\s*\(\s*"(NX_[A-Z0-9_]+)"/g;
|
||||
const RUST_FROM_ENV = /(?:try_)?from_env\s*\(\s*"(NX_[A-Z0-9_]+)"/g;
|
||||
const DOCS_TABLE_ROW = /^\|\s*`(NX_[A-Z0-9_]+)`/gm;
|
||||
|
||||
export default createConformanceRule<Options>({
|
||||
name: 'env-vars-documented',
|
||||
category: 'consistency',
|
||||
description:
|
||||
'Ensures every NX_* environment variable in source code is covered by docs',
|
||||
implementation: async ({ tree, fileMapCache, ruleOptions }) => {
|
||||
const ignore = new Set(ruleOptions?.ignore ?? []);
|
||||
|
||||
const docsContent = tree.read(DOCS_PATH, 'utf-8');
|
||||
if (!docsContent) {
|
||||
return {
|
||||
severity: 'high',
|
||||
details: {
|
||||
violations: [
|
||||
{
|
||||
message: `Could not read ${DOCS_PATH}. The conformance rule expects to run from the workspace root.`,
|
||||
file: DOCS_PATH,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
const documented = extractDocumentedVars(docsContent);
|
||||
|
||||
const nxFiles =
|
||||
fileMapCache.fileMap.projectFileMap?.[NX_CORE_PROJECT] ?? [];
|
||||
const filesToScan = nxFiles
|
||||
.map(({ file }) => file)
|
||||
.filter(isScannableSourceFile);
|
||||
const usages = extractUsagesFromFiles(tree, filesToScan);
|
||||
|
||||
const violations: ConformanceViolation[] = [];
|
||||
for (const [name, files] of usages) {
|
||||
if (documented.has(name) || ignore.has(name)) continue;
|
||||
const examples = [...files].join(', ');
|
||||
violations.push({
|
||||
message: `Env var \`${name}\` not documented. Found in ${examples}. Add a row to ${DOCS_PATH} or list \`${name}\` in the rule's "ignore" option.`,
|
||||
file: DOCS_PATH,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
severity: violations.length > 0 ? 'medium' : 'low',
|
||||
details: { violations },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
function isScannableSourceFile(file: string): boolean {
|
||||
if (!SCANNABLE_EXT.test(file)) return false;
|
||||
if (TEST_FILE.test(file)) return false;
|
||||
if (EXCLUDED_SEGMENT.test(file)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function extractUsagesFromFiles(
|
||||
tree: Tree,
|
||||
files: string[]
|
||||
): Map<string, Set<string>> {
|
||||
const usages = new Map<string, Set<string>>();
|
||||
for (const file of files) {
|
||||
const content = tree.read(file, 'utf-8');
|
||||
if (!content) continue;
|
||||
for (const name of extractUsedVarsFromContent(content, file)) {
|
||||
let set = usages.get(name);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
usages.set(name, set);
|
||||
}
|
||||
if (set.size < MAX_EXAMPLE_FILES) set.add(file);
|
||||
}
|
||||
}
|
||||
return usages;
|
||||
}
|
||||
|
||||
export function extractDocumentedVars(mdocContent: string): Set<string> {
|
||||
return new Set(Array.from(mdocContent.matchAll(DOCS_TABLE_ROW), (m) => m[1]));
|
||||
}
|
||||
|
||||
export function extractUsedVarsFromContent(
|
||||
content: string,
|
||||
file: string
|
||||
): string[] {
|
||||
const patterns = file.endsWith('.rs')
|
||||
? [RUST_ENV_VAR, RUST_FROM_ENV]
|
||||
: [TS_JS_USAGE];
|
||||
const found: string[] = [];
|
||||
for (const pattern of patterns) {
|
||||
for (const m of content.matchAll(pattern)) {
|
||||
found.push(m[1]);
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ignore": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "NX_* env var names that are intentionally not part of the user-facing documented contract (internal, test-only, or deprecated aliases)."
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
jest.mock('node:fs', () => {
|
||||
return {
|
||||
...jest.requireActual('node:fs'),
|
||||
};
|
||||
});
|
||||
import * as fs from 'node:fs';
|
||||
import { validateMigrations } from './index';
|
||||
|
||||
describe('migration-groups', () => {
|
||||
let mockExistsSync: jest.SpyInstance;
|
||||
beforeEach(() => {
|
||||
mockExistsSync = jest.spyOn(fs, 'existsSync');
|
||||
});
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
// Unit test the core implementation details of validating the project package.json
|
||||
describe('validateMigrations()', () => {
|
||||
it('should return no violations when migrations do not include packageJsonUpdates', () => {
|
||||
const migrations = {};
|
||||
const sourceProject = 'test-project';
|
||||
const sourceProjectRoot = '/path/to/test-project';
|
||||
const violations = validateMigrations(
|
||||
migrations,
|
||||
sourceProject,
|
||||
`${sourceProjectRoot}/migrations.json`,
|
||||
{ groups: [['@acme/foo', '@acme/bar']] }
|
||||
);
|
||||
expect(violations).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should return no violations for a valid packageJsonUpdates', () => {
|
||||
const migrations = {
|
||||
packageJsonUpdates: {
|
||||
'0.0.1': {
|
||||
version: '0.0.1',
|
||||
packages: {
|
||||
'@acme/foo': {
|
||||
version: '1.0.0',
|
||||
},
|
||||
'@acme/bar': {
|
||||
version: '1.0.0',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const sourceProject = 'test-project';
|
||||
const sourceProjectRoot = '/path/to/test-project';
|
||||
const violations = validateMigrations(
|
||||
migrations,
|
||||
sourceProject,
|
||||
`${sourceProjectRoot}/migrations.json`,
|
||||
{ groups: [['@acme/foo', '@acme/bar']] }
|
||||
);
|
||||
expect(violations).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should return violations for missing packages in a group', () => {
|
||||
const migrations = {
|
||||
packageJsonUpdates: {
|
||||
'0.0.1': {
|
||||
version: '0.0.1',
|
||||
packages: {
|
||||
'@acme/foo': {
|
||||
version: '1.0.0',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const sourceProject = 'test-project';
|
||||
const sourceProjectRoot = '/path/to/test-project';
|
||||
const violations = validateMigrations(
|
||||
migrations,
|
||||
sourceProject,
|
||||
`${sourceProjectRoot}/migrations.json`,
|
||||
{ groups: [['@acme/foo', '@acme/bar']] }
|
||||
);
|
||||
expect(violations).toMatchInlineSnapshot(`
|
||||
[
|
||||
{
|
||||
"file": "/path/to/test-project/migrations.json",
|
||||
"message": "Package.json updates for "0.0.1" is missing packages in a group: @acme/bar. Versions of packages in a group must have their versions synced. Version: 1.0.0.
|
||||
",
|
||||
"sourceProject": "test-project",
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it('should return violations for mismatched versions for packages in a group', () => {
|
||||
const migrations = {
|
||||
packageJsonUpdates: {
|
||||
'0.0.1': {
|
||||
version: '0.0.1',
|
||||
packages: {
|
||||
'@acme/foo': {
|
||||
version: '1.0.0',
|
||||
},
|
||||
'@acme/bar': {
|
||||
version: '~1.0.0',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const sourceProject = 'test-project';
|
||||
const sourceProjectRoot = '/path/to/test-project';
|
||||
const violations = validateMigrations(
|
||||
migrations,
|
||||
sourceProject,
|
||||
`${sourceProjectRoot}/migrations.json`,
|
||||
{ groups: [['@acme/foo', '@acme/bar']] }
|
||||
);
|
||||
expect(violations).toMatchInlineSnapshot(`
|
||||
[
|
||||
{
|
||||
"file": "/path/to/test-project/migrations.json",
|
||||
"message": "Package.json updates for "0.0.1" has mismatched versions in a package group: 1.0.0, ~1.0.0. Versions of packages in a group must be in sync. Packages in the group: @acme/foo, @acme/bar",
|
||||
"sourceProject": "test-project",
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it('should ignore migrations not matching versionRange', () => {
|
||||
const migrations = {
|
||||
packageJsonUpdates: {
|
||||
'0.0.1': {
|
||||
version: '0.0.1',
|
||||
packages: {
|
||||
'@acme/foo': {
|
||||
version: '1.0.0',
|
||||
},
|
||||
},
|
||||
},
|
||||
'1.0.0': {
|
||||
version: '1.0.0',
|
||||
packages: {
|
||||
'@acme/foo': {
|
||||
version: '1.0.0',
|
||||
},
|
||||
'@acme/bar': {
|
||||
version: '1.0.0',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const sourceProject = 'test-project';
|
||||
const sourceProjectRoot = '/path/to/test-project';
|
||||
const violations = validateMigrations(
|
||||
migrations,
|
||||
sourceProject,
|
||||
`${sourceProjectRoot}/migrations.json`,
|
||||
{ groups: [['@acme/foo', '@acme/bar']], versionRange: '>= 1' }
|
||||
);
|
||||
expect(violations).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import {
|
||||
createConformanceRule,
|
||||
type ConformanceViolation,
|
||||
} from '@nx/conformance';
|
||||
import { readJsonFile, workspaceRoot } from '@nx/devkit';
|
||||
import { existsSync } from 'fs';
|
||||
import { join } from 'node:path';
|
||||
import { satisfies } from 'semver';
|
||||
|
||||
type Options = {
|
||||
groups: Array<string[]>;
|
||||
versionRange?: string;
|
||||
};
|
||||
|
||||
export default createConformanceRule<Options>({
|
||||
name: 'migration-groups',
|
||||
category: 'consistency',
|
||||
description:
|
||||
'Ensures that packageJsonUpdates in migrations.json have all packages included from groups. e.g. @typescript-eslint/* packages must be in sync',
|
||||
implementation: async ({ projectGraph, ruleOptions }) => {
|
||||
const violations: ConformanceViolation[] = [];
|
||||
|
||||
for (const project of Object.values(projectGraph.nodes)) {
|
||||
if (
|
||||
project.name !== 'angular' &&
|
||||
project.name !== 'eslint' &&
|
||||
project.name !== 'storybook'
|
||||
)
|
||||
continue;
|
||||
const migrationsPath = join(
|
||||
workspaceRoot,
|
||||
project.data.root,
|
||||
'migrations.json'
|
||||
);
|
||||
if (existsSync(migrationsPath)) {
|
||||
const migrations = readJsonFile(migrationsPath);
|
||||
violations.push(
|
||||
...validateMigrations(
|
||||
migrations,
|
||||
project.name,
|
||||
migrationsPath,
|
||||
ruleOptions
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
severity: 'high',
|
||||
details: {
|
||||
violations,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export function validateMigrations(
|
||||
migrations: Record<string, unknown>,
|
||||
sourceProject: string,
|
||||
migrationsPath: string,
|
||||
options: Options
|
||||
): ConformanceViolation[] {
|
||||
if (!migrations.packageJsonUpdates) return [];
|
||||
|
||||
const violations: ConformanceViolation[] = [];
|
||||
|
||||
// Check that if package updates include one package in the group, then:
|
||||
// 1. They all have the same version
|
||||
// 2. Every package from group is included
|
||||
for (const [key, value] of Object.entries(migrations.packageJsonUpdates)) {
|
||||
if (!value.packages || !value.version) continue;
|
||||
if (
|
||||
options.versionRange &&
|
||||
!satisfies(value.version, options.versionRange, {
|
||||
includePrerelease: true,
|
||||
})
|
||||
)
|
||||
continue;
|
||||
const packages = Object.keys(value.packages);
|
||||
for (const group of options.groups) {
|
||||
if (!group.some((pkg) => packages.includes(pkg))) continue;
|
||||
|
||||
const versions = new Set<string>(
|
||||
group.map((pkg) => value.packages[pkg]?.version).filter(Boolean)
|
||||
);
|
||||
if (versions.size > 1) {
|
||||
violations.push({
|
||||
message: `Package.json updates for "${key}" has mismatched versions in a package group: ${Array.from(
|
||||
versions
|
||||
).join(
|
||||
', '
|
||||
)}. Versions of packages in a group must be in sync. Packages in the group: ${group.join(
|
||||
', '
|
||||
)}`,
|
||||
sourceProject,
|
||||
file: migrationsPath,
|
||||
});
|
||||
}
|
||||
|
||||
const result = group.reduce(
|
||||
(acc, pkg) => {
|
||||
if (packages.includes(pkg)) acc.present.push(pkg);
|
||||
else acc.missing.push(pkg);
|
||||
return acc;
|
||||
},
|
||||
{ missing: [] as string[], present: [] as string[] }
|
||||
);
|
||||
if (result.missing.length > 0) {
|
||||
violations.push({
|
||||
message: `Package.json updates for "${key}" is missing packages in a group: ${result.missing.join(
|
||||
', '
|
||||
)}. Versions of packages in a group must have their versions synced. ${
|
||||
versions.size === 1
|
||||
? `Version: ${Array.from(versions)[0]}.`
|
||||
: `Versions: ${Array.from(versions).join(',')} (choose one).`
|
||||
}
|
||||
`,
|
||||
sourceProject,
|
||||
file: migrationsPath,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return violations;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"groups": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"versionRange": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": ["groups"]
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { createConformanceRule } from '@nx/conformance';
|
||||
import type { ConformanceViolation } from '@nx/conformance';
|
||||
import { execSync } from 'child_process';
|
||||
|
||||
export default createConformanceRule({
|
||||
name: 'no-ignored-tracked-files',
|
||||
category: 'reliability',
|
||||
description:
|
||||
'There should be no files that are both tracked by git and ignored by .gitignore. Git supports this, but in an nx workspace this results in files which are present but unaccounted for in hash calculations and is almost always a mistake.',
|
||||
implementation: async (context) => {
|
||||
const violations: ConformanceViolation[] = [];
|
||||
|
||||
try {
|
||||
// Run git ls-files to find tracked files that would be ignored
|
||||
// -i: Show only ignored files
|
||||
// --exclude-standard: Use standard git exclusions (.gitignore, .git/info/exclude, etc.)
|
||||
// -c: Show cached (tracked) files
|
||||
const result = execSync('git ls-files -i --exclude-standard -c', {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
}).trim();
|
||||
|
||||
if (result) {
|
||||
const ignoredFiles = result
|
||||
.split('\n')
|
||||
.filter((file) => file.length > 0);
|
||||
|
||||
ignoredFiles.forEach((file) => {
|
||||
violations.push({
|
||||
file,
|
||||
message: `File "${file}" is tracked by git but would be ignored if not already tracked. This can cause issues with Nx hashing. Either:\n - Remove from tracking: git rm --cached ${file} && git commit\n - Update .gitignore to exclude it from ignore patterns\n - Use more specific patterns in .gitignore (e.g., /node_modules/ instead of node_modules)`,
|
||||
});
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
// If the command fails, it might be because we're not in a git repository
|
||||
// or git is not available. In conformance rules, we should handle this gracefully.
|
||||
if (error.message?.includes('not a git repository')) {
|
||||
// Not in a git repository - no violations to report
|
||||
return {
|
||||
severity: 'low',
|
||||
details: {
|
||||
violations: [],
|
||||
},
|
||||
};
|
||||
} else if (error.message?.includes('command not found')) {
|
||||
// Git is not installed - skip this rule
|
||||
return {
|
||||
severity: 'low',
|
||||
details: {
|
||||
violations: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
// For other errors, we can log them but continue
|
||||
console.warn(
|
||||
'Warning: Could not check for git-ignored tracked files:',
|
||||
error.message
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
severity: violations.length > 0 ? 'medium' : 'low',
|
||||
details: {
|
||||
violations,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/schema",
|
||||
"$id": "no-ignored-tracked-files",
|
||||
"title": "no-ignored-tracked-files rule configuration",
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
jest.mock('fs');
|
||||
import * as fs from 'fs';
|
||||
|
||||
import { validateProjectPackageJson } from './index';
|
||||
|
||||
const VALID_PACKAGE_JSON_BASE = {
|
||||
name: '@nx/test-project',
|
||||
publishConfig: {
|
||||
access: 'public',
|
||||
},
|
||||
exports: {
|
||||
'./package.json': './package.json',
|
||||
},
|
||||
};
|
||||
|
||||
describe('project-package-json', () => {
|
||||
let mockExistsSync: jest.SpyInstance;
|
||||
beforeEach(() => {
|
||||
mockExistsSync = jest.spyOn(fs, 'existsSync');
|
||||
});
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
// Unit test the core implementation details of validating the project package.json
|
||||
describe('validateProjectPackageJson()', () => {
|
||||
it('should return no violations for a valid project package.json', () => {
|
||||
const packageJson = {
|
||||
...VALID_PACKAGE_JSON_BASE,
|
||||
};
|
||||
const sourceProject = 'test-project';
|
||||
const sourceProjectRoot = '/path/to/test-project';
|
||||
const violations = validateProjectPackageJson(
|
||||
packageJson,
|
||||
sourceProject,
|
||||
sourceProjectRoot,
|
||||
`${sourceProjectRoot}/package.json`
|
||||
);
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return a violation if the name is not a string', () => {
|
||||
const packageJson = {
|
||||
...VALID_PACKAGE_JSON_BASE,
|
||||
};
|
||||
delete packageJson.name;
|
||||
|
||||
const sourceProject = 'test-project';
|
||||
const sourceProjectRoot = '/path/to/test-project';
|
||||
const violations = validateProjectPackageJson(
|
||||
packageJson,
|
||||
sourceProject,
|
||||
sourceProjectRoot,
|
||||
`${sourceProjectRoot}/package.json`
|
||||
);
|
||||
expect(violations).toMatchInlineSnapshot(`
|
||||
[
|
||||
{
|
||||
"file": "/path/to/test-project/package.json",
|
||||
"message": "The project package.json should have a "name" field",
|
||||
"sourceProject": "test-project",
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it('should return a violation if the name is not scoped an org that is not @nx', () => {
|
||||
const sourceProject = 'test-project';
|
||||
const sourceProjectRoot = '/path/to/test-project';
|
||||
|
||||
expect(
|
||||
validateProjectPackageJson(
|
||||
// Should be fine, as not scoped
|
||||
{
|
||||
...VALID_PACKAGE_JSON_BASE,
|
||||
name: 'test-project',
|
||||
},
|
||||
sourceProject,
|
||||
sourceProjectRoot,
|
||||
`${sourceProjectRoot}/package.json`
|
||||
)
|
||||
).toEqual([]);
|
||||
|
||||
// Should return a violation, as scoped to an org that is not @nx
|
||||
const packageJsonWithScope = {
|
||||
...VALID_PACKAGE_JSON_BASE,
|
||||
name: '@nx-labs/test-project',
|
||||
};
|
||||
expect(
|
||||
validateProjectPackageJson(
|
||||
packageJsonWithScope,
|
||||
sourceProject,
|
||||
sourceProjectRoot,
|
||||
`${sourceProjectRoot}/package.json`
|
||||
)
|
||||
).toMatchInlineSnapshot(`
|
||||
[
|
||||
{
|
||||
"file": "/path/to/test-project/package.json",
|
||||
"message": "The package name should be scoped to the @nx org",
|
||||
"sourceProject": "test-project",
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it('should return a violation if a public package does not have publishConfig.access set to public', () => {
|
||||
const sourceProject = 'some-project-name';
|
||||
const sourceProjectRoot = '/path/to/some-project-name';
|
||||
|
||||
expect(
|
||||
validateProjectPackageJson(
|
||||
// Should be fine, as private
|
||||
{
|
||||
private: true,
|
||||
name: 'test-project',
|
||||
},
|
||||
sourceProject,
|
||||
sourceProjectRoot,
|
||||
`${sourceProjectRoot}/package.json`
|
||||
)
|
||||
).toEqual([]);
|
||||
|
||||
// Should return a violation, as not private
|
||||
const packageJsonWithoutPublicAccess = {
|
||||
...VALID_PACKAGE_JSON_BASE,
|
||||
};
|
||||
delete packageJsonWithoutPublicAccess.publishConfig;
|
||||
expect(
|
||||
validateProjectPackageJson(
|
||||
packageJsonWithoutPublicAccess,
|
||||
sourceProject,
|
||||
sourceProjectRoot,
|
||||
`${sourceProjectRoot}/package.json`
|
||||
)
|
||||
).toMatchInlineSnapshot(`
|
||||
[
|
||||
{
|
||||
"file": "/path/to/some-project-name/package.json",
|
||||
"message": "Public packages should have "publishConfig": { "access": "public" } set in their package.json",
|
||||
"sourceProject": "some-project-name",
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it('should return a violation if the project has an executors.json but does not reference it in the package.json', () => {
|
||||
const sourceProject = 'some-project-name';
|
||||
const sourceProjectRoot = '/path/to/some-project-name';
|
||||
|
||||
// The project does not have an executors.json, so no violation
|
||||
expect(
|
||||
validateProjectPackageJson(
|
||||
{
|
||||
...VALID_PACKAGE_JSON_BASE,
|
||||
},
|
||||
sourceProject,
|
||||
sourceProjectRoot,
|
||||
`${sourceProjectRoot}/package.json`
|
||||
)
|
||||
).toEqual([]);
|
||||
|
||||
// The project has an executors.json
|
||||
mockExistsSync.mockImplementation((path) => {
|
||||
if (path.endsWith('executors.json')) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
// The project references the executors.json in the package.json, so no violation
|
||||
expect(
|
||||
validateProjectPackageJson(
|
||||
{
|
||||
...VALID_PACKAGE_JSON_BASE,
|
||||
executors: './executors.json',
|
||||
},
|
||||
sourceProject,
|
||||
sourceProjectRoot,
|
||||
`${sourceProjectRoot}/package.json`
|
||||
)
|
||||
).toEqual([]);
|
||||
|
||||
// The project does not reference the executors.json in the package.json, so a violation is returned
|
||||
expect(
|
||||
validateProjectPackageJson(
|
||||
{
|
||||
...VALID_PACKAGE_JSON_BASE,
|
||||
},
|
||||
sourceProject,
|
||||
sourceProjectRoot,
|
||||
`${sourceProjectRoot}/package.json`
|
||||
)
|
||||
).toMatchInlineSnapshot(`
|
||||
[
|
||||
{
|
||||
"file": "/path/to/some-project-name/package.json",
|
||||
"message": "The project has an executors.json, but does not reference "./executors.json" in the "executors" field of its package.json",
|
||||
"sourceProject": "some-project-name",
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it('should return a violation if the project has an generators.json but does not reference it in the package.json', () => {
|
||||
const sourceProject = 'some-project-name';
|
||||
const sourceProjectRoot = '/path/to/some-project-name';
|
||||
|
||||
// The project does not have an generators.json, so no violation
|
||||
expect(
|
||||
validateProjectPackageJson(
|
||||
{
|
||||
...VALID_PACKAGE_JSON_BASE,
|
||||
},
|
||||
sourceProject,
|
||||
sourceProjectRoot,
|
||||
`${sourceProjectRoot}/package.json`
|
||||
)
|
||||
).toEqual([]);
|
||||
|
||||
// The project has an generators.json
|
||||
mockExistsSync.mockImplementation((path) => {
|
||||
if (path.endsWith('generators.json')) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
// The project references the generators.json in the package.json, so no violation
|
||||
expect(
|
||||
validateProjectPackageJson(
|
||||
{
|
||||
...VALID_PACKAGE_JSON_BASE,
|
||||
generators: './generators.json',
|
||||
},
|
||||
sourceProject,
|
||||
sourceProjectRoot,
|
||||
`${sourceProjectRoot}/package.json`
|
||||
)
|
||||
).toEqual([]);
|
||||
|
||||
// The project does not reference the generators.json in the package.json, so a violation is returned
|
||||
expect(
|
||||
validateProjectPackageJson(
|
||||
{
|
||||
...VALID_PACKAGE_JSON_BASE,
|
||||
},
|
||||
sourceProject,
|
||||
sourceProjectRoot,
|
||||
`${sourceProjectRoot}/package.json`
|
||||
)
|
||||
).toMatchInlineSnapshot(`
|
||||
[
|
||||
{
|
||||
"file": "/path/to/some-project-name/package.json",
|
||||
"message": "The project has an generators.json, but does not reference "./generators.json" in the "generators" field of its package.json",
|
||||
"sourceProject": "some-project-name",
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it('should return a violation if the project does not specify an exports object in the package.json', () => {
|
||||
const sourceProject = 'test-project';
|
||||
const sourceProjectRoot = '/path/to/test-project';
|
||||
|
||||
expect(
|
||||
validateProjectPackageJson(
|
||||
{
|
||||
...VALID_PACKAGE_JSON_BASE,
|
||||
exports: undefined,
|
||||
},
|
||||
sourceProject,
|
||||
sourceProjectRoot,
|
||||
`${sourceProjectRoot}/package.json`
|
||||
)
|
||||
).toMatchInlineSnapshot(`
|
||||
[
|
||||
{
|
||||
"file": "/path/to/test-project/package.json",
|
||||
"message": "The project package.json should have an "exports" object specified",
|
||||
"sourceProject": "test-project",
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import {
|
||||
createConformanceRule,
|
||||
type ConformanceViolation,
|
||||
} from '@nx/conformance';
|
||||
import { readJsonFile, workspaceRoot } from '@nx/devkit';
|
||||
import { existsSync } from 'fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
export default createConformanceRule<object>({
|
||||
name: 'project-package-json',
|
||||
category: 'consistency',
|
||||
description:
|
||||
'Ensures consistency across our project package.json files within the Nx repo',
|
||||
implementation: async ({ projectGraph }) => {
|
||||
const violations: ConformanceViolation[] = [];
|
||||
|
||||
for (const project of Object.values(projectGraph.nodes)) {
|
||||
const projectPackageJsonPath = join(
|
||||
workspaceRoot,
|
||||
project.data.root,
|
||||
'package.json'
|
||||
);
|
||||
if (existsSync(projectPackageJsonPath)) {
|
||||
const projectPackageJson = readJsonFile(projectPackageJsonPath);
|
||||
violations.push(
|
||||
...validateProjectPackageJson(
|
||||
projectPackageJson,
|
||||
project.name,
|
||||
project.data.root,
|
||||
projectPackageJsonPath
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
severity: 'medium',
|
||||
details: {
|
||||
violations,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export function validateProjectPackageJson(
|
||||
projectPackageJson: Record<string, unknown>,
|
||||
sourceProject: string,
|
||||
sourceProjectRoot: string,
|
||||
projectPackageJsonPath: string
|
||||
): ConformanceViolation[] {
|
||||
const violations: ConformanceViolation[] = [];
|
||||
|
||||
// Private packages are exempt from this rule
|
||||
if (projectPackageJson.private === true) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (typeof projectPackageJson.name !== 'string') {
|
||||
violations.push({
|
||||
message: 'The project package.json should have a "name" field',
|
||||
sourceProject,
|
||||
file: projectPackageJsonPath,
|
||||
});
|
||||
} else {
|
||||
// Ensure that if a scope is used, it is only the @nx scope
|
||||
if (
|
||||
projectPackageJson.name.startsWith('@') &&
|
||||
!projectPackageJson.name.startsWith('@nx/')
|
||||
) {
|
||||
violations.push({
|
||||
message: 'The package name should be scoped to the @nx org',
|
||||
sourceProject,
|
||||
file: projectPackageJsonPath,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Publish config
|
||||
if ((projectPackageJson.publishConfig as any)?.access !== 'public') {
|
||||
violations.push({
|
||||
message:
|
||||
'Public packages should have "publishConfig": { "access": "public" } set in their package.json',
|
||||
sourceProject,
|
||||
file: projectPackageJsonPath,
|
||||
});
|
||||
}
|
||||
|
||||
// Nx config properties
|
||||
if (existsSync(join(sourceProjectRoot, 'executors.json'))) {
|
||||
if (projectPackageJson.executors !== './executors.json') {
|
||||
violations.push({
|
||||
message:
|
||||
'The project has an executors.json, but does not reference "./executors.json" in the "executors" field of its package.json',
|
||||
sourceProject,
|
||||
file: projectPackageJsonPath,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (existsSync(join(sourceProjectRoot, 'generators.json'))) {
|
||||
if (projectPackageJson.generators !== './generators.json') {
|
||||
violations.push({
|
||||
message:
|
||||
'The project has an generators.json, but does not reference "./generators.json" in the "generators" field of its package.json',
|
||||
sourceProject,
|
||||
file: projectPackageJsonPath,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const hasExportsEntries =
|
||||
typeof projectPackageJson.exports === 'object' &&
|
||||
Object.keys(projectPackageJson.exports ?? {}).length > 0;
|
||||
|
||||
if (!hasExportsEntries) {
|
||||
violations.push({
|
||||
message:
|
||||
'The project package.json should have an "exports" object specified',
|
||||
sourceProject,
|
||||
file: projectPackageJsonPath,
|
||||
});
|
||||
}
|
||||
|
||||
return violations;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { createConformanceRule } from '@nx/conformance';
|
||||
import type { ConformanceViolation } from '@nx/conformance';
|
||||
import { workspaceRoot } from '@nx/devkit';
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { dirname, join, resolve, relative, isAbsolute } from 'node:path';
|
||||
import { globSync } from 'tinyglobby';
|
||||
|
||||
const ignorePatterns = ['**/node_modules/**', '**/dist/**', '**/.astro/**'];
|
||||
|
||||
// Image file extensions to check
|
||||
const imageExtensions = ['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp'];
|
||||
|
||||
export default createConformanceRule({
|
||||
name: 'image-import-paths',
|
||||
category: 'consistency',
|
||||
description: 'Ensures that all doc image references are valid',
|
||||
implementation: async ({ projectGraph }) => {
|
||||
const violations: ConformanceViolation[] = [];
|
||||
|
||||
// Find all .mdoc files in astro-docs
|
||||
const mdocFiles = globSync('astro-docs/**/*.mdoc', {
|
||||
cwd: workspaceRoot,
|
||||
ignore: ignorePatterns,
|
||||
absolute: true,
|
||||
});
|
||||
|
||||
for (const file of mdocFiles) {
|
||||
const content = readFileSync(file, 'utf-8');
|
||||
const fileViolations = checkImagePaths(content, file);
|
||||
violations.push(...fileViolations);
|
||||
}
|
||||
|
||||
return {
|
||||
severity: violations.length > 0 ? 'medium' : 'low',
|
||||
details: {
|
||||
violations,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
function checkImagePaths(
|
||||
content: string,
|
||||
filePath: string
|
||||
): ConformanceViolation[] {
|
||||
const violations: ConformanceViolation[] = [];
|
||||
const lines = content.split('\n');
|
||||
let inCodeBlock = false;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
// Track code block state
|
||||
if (line.trim().startsWith('```')) {
|
||||
inCodeBlock = !inCodeBlock;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip lines inside code blocks
|
||||
if (inCodeBlock) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for markdown image syntax: 
|
||||
const markdownImageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g;
|
||||
let match;
|
||||
|
||||
while ((match = markdownImageRegex.exec(line)) !== null) {
|
||||
const imagePath = match[2];
|
||||
|
||||
// Skip URLs (http://, https://, //)
|
||||
if (
|
||||
imagePath.startsWith('http://') ||
|
||||
imagePath.startsWith('https://') ||
|
||||
imagePath.startsWith('//')
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const violation = validateImagePath(imagePath, filePath, i + 1);
|
||||
if (violation) {
|
||||
violations.push(violation);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for import statements: import ... from './path/to/image.png'
|
||||
const importRegex = /import\s+.*\s+from\s+['"]([^'"]+)['"]/;
|
||||
const importMatch = line.match(importRegex);
|
||||
|
||||
if (importMatch) {
|
||||
const importPath = importMatch[1];
|
||||
|
||||
// Check if this is an image import
|
||||
const hasImageExtension = imageExtensions.some((ext) =>
|
||||
importPath.toLowerCase().endsWith(ext)
|
||||
);
|
||||
|
||||
if (hasImageExtension) {
|
||||
const violation = validateImagePath(importPath, filePath, i + 1);
|
||||
if (violation) {
|
||||
violations.push(violation);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return violations;
|
||||
}
|
||||
|
||||
function validateImagePath(
|
||||
imagePath: string,
|
||||
mdocFilePath: string,
|
||||
lineNumber: number
|
||||
): ConformanceViolation | null {
|
||||
// Handle paths starting with / (public folder references in Astro)
|
||||
if (imagePath.startsWith('/')) {
|
||||
const publicPath = join(workspaceRoot, 'astro-docs/public', imagePath);
|
||||
|
||||
if (!existsSync(publicPath)) {
|
||||
return {
|
||||
message: `Image path at line ${lineNumber} does not exist in public folder: "${imagePath}"`,
|
||||
file: resolveFilePathToWorkspaceRoot(mdocFilePath),
|
||||
};
|
||||
}
|
||||
|
||||
// Path exists in public folder - this is valid
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if path is absolute (not relative)
|
||||
if (
|
||||
isAbsolute(imagePath) ||
|
||||
(!imagePath.startsWith('.') && !imagePath.startsWith('..'))
|
||||
) {
|
||||
return {
|
||||
message: `Image path at line ${lineNumber} must be relative (start with ./ or ../) or an absolute path to public folder (start with /). Found: "${imagePath}"`,
|
||||
file: resolveFilePathToWorkspaceRoot(mdocFilePath),
|
||||
};
|
||||
}
|
||||
|
||||
// Resolve the full path to the image
|
||||
const mdocDir = dirname(mdocFilePath);
|
||||
const resolvedImagePath = resolve(mdocDir, imagePath);
|
||||
|
||||
// Check if the file exists
|
||||
if (!existsSync(resolvedImagePath)) {
|
||||
return {
|
||||
message: `Image path at line ${lineNumber} does not exist: "${imagePath}"`,
|
||||
file: resolveFilePathToWorkspaceRoot(mdocFilePath),
|
||||
};
|
||||
}
|
||||
|
||||
// Relative paths must be in src/assets folder only
|
||||
const relativeToWorkspace = relative(workspaceRoot, resolvedImagePath);
|
||||
const isInAssets = relativeToWorkspace.startsWith('astro-docs/src/assets/');
|
||||
|
||||
if (!isInAssets) {
|
||||
return {
|
||||
message: `Image at line ${lineNumber} with relative path must be in astro-docs/src/assets/ folder. Found: "${relativeToWorkspace}". If the image is in the public folder, use an absolute path starting with /`,
|
||||
file: resolveFilePathToWorkspaceRoot(mdocFilePath),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveFilePathToWorkspaceRoot(filePath: string) {
|
||||
if (!filePath || !filePath.startsWith(workspaceRoot)) {
|
||||
return filePath;
|
||||
}
|
||||
|
||||
return filePath.replace(workspaceRoot + '/', '');
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": false
|
||||
}
|
||||
+240
@@ -0,0 +1,240 @@
|
||||
import { validateTypesVersionsExportsSync } from './index';
|
||||
|
||||
const SOURCE_PROJECT = 'test-project';
|
||||
const PACKAGE_JSON_PATH = '/path/to/test-project/package.json';
|
||||
|
||||
describe('types-versions-exports-sync', () => {
|
||||
describe('validateTypesVersionsExportsSync()', () => {
|
||||
it('should return no violations when both fields are fully in sync', () => {
|
||||
const violations = validateTypesVersionsExportsSync(
|
||||
{
|
||||
typesVersions: {
|
||||
'*': {
|
||||
'src/utils': ['dist/src/utils/index.d.ts'],
|
||||
'src/*': ['dist/src/*.d.ts'],
|
||||
},
|
||||
},
|
||||
exports: {
|
||||
'.': {
|
||||
types: './dist/index.d.ts',
|
||||
default: './dist/index.js',
|
||||
},
|
||||
'./src/utils': {
|
||||
types: './dist/src/utils/index.d.ts',
|
||||
default: './dist/src/utils/index.js',
|
||||
},
|
||||
'./src/*': {
|
||||
types: './dist/src/*.d.ts',
|
||||
default: './dist/src/*.js',
|
||||
},
|
||||
},
|
||||
},
|
||||
SOURCE_PROJECT,
|
||||
PACKAGE_JSON_PATH
|
||||
);
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return no violations when package has only exports', () => {
|
||||
const violations = validateTypesVersionsExportsSync(
|
||||
{
|
||||
exports: {
|
||||
'.': {
|
||||
types: './dist/index.d.ts',
|
||||
default: './dist/index.js',
|
||||
},
|
||||
},
|
||||
},
|
||||
SOURCE_PROJECT,
|
||||
PACKAGE_JSON_PATH
|
||||
);
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return no violations when package has only typesVersions', () => {
|
||||
const violations = validateTypesVersionsExportsSync(
|
||||
{
|
||||
typesVersions: {
|
||||
'*': {
|
||||
'src/*': ['dist/src/*.d.ts'],
|
||||
},
|
||||
},
|
||||
},
|
||||
SOURCE_PROJECT,
|
||||
PACKAGE_JSON_PATH
|
||||
);
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return no violations for exports string shorthand entries', () => {
|
||||
const violations = validateTypesVersionsExportsSync(
|
||||
{
|
||||
typesVersions: {
|
||||
'*': {
|
||||
'src/*': ['dist/src/*.d.ts'],
|
||||
},
|
||||
},
|
||||
exports: {
|
||||
'./package.json': './package.json',
|
||||
'./src/*': {
|
||||
types: './dist/src/*.d.ts',
|
||||
default: './dist/src/*.js',
|
||||
},
|
||||
},
|
||||
},
|
||||
SOURCE_PROJECT,
|
||||
PACKAGE_JSON_PATH
|
||||
);
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return no violations for exports entries without a types condition', () => {
|
||||
const violations = validateTypesVersionsExportsSync(
|
||||
{
|
||||
typesVersions: {
|
||||
'*': {
|
||||
'src/*': ['dist/src/*.d.ts'],
|
||||
},
|
||||
},
|
||||
exports: {
|
||||
'./presets/*': {
|
||||
default: './dist/presets/*',
|
||||
},
|
||||
'./src/*': {
|
||||
types: './dist/src/*.d.ts',
|
||||
default: './dist/src/*.js',
|
||||
},
|
||||
},
|
||||
},
|
||||
SOURCE_PROJECT,
|
||||
PACKAGE_JSON_PATH
|
||||
);
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return a violation when exports has types but no typesVersions entry', () => {
|
||||
const violations = validateTypesVersionsExportsSync(
|
||||
{
|
||||
typesVersions: {
|
||||
'*': {
|
||||
'src/*': ['dist/src/*.d.ts'],
|
||||
},
|
||||
},
|
||||
exports: {
|
||||
'./src/*': {
|
||||
types: './dist/src/*.d.ts',
|
||||
default: './dist/src/*.js',
|
||||
},
|
||||
'./release': {
|
||||
types: './dist/release/index.d.ts',
|
||||
default: './dist/release/index.js',
|
||||
},
|
||||
},
|
||||
},
|
||||
SOURCE_PROJECT,
|
||||
PACKAGE_JSON_PATH
|
||||
);
|
||||
expect(violations).toMatchInlineSnapshot(`
|
||||
[
|
||||
{
|
||||
"file": "/path/to/test-project/package.json",
|
||||
"message": "The exports entry './release' has a 'types' condition but no corresponding 'typesVersions' entry. Add 'release' to 'typesVersions' to support node10 module resolution.",
|
||||
"sourceProject": "test-project",
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it('should return a violation when types paths mismatch', () => {
|
||||
const violations = validateTypesVersionsExportsSync(
|
||||
{
|
||||
typesVersions: {
|
||||
'*': {
|
||||
'src/utils': ['dist/src/utils.d.ts'],
|
||||
},
|
||||
},
|
||||
exports: {
|
||||
'./src/utils': {
|
||||
types: './dist/src/utils/index.d.ts',
|
||||
default: './dist/src/utils/index.js',
|
||||
},
|
||||
},
|
||||
},
|
||||
SOURCE_PROJECT,
|
||||
PACKAGE_JSON_PATH
|
||||
);
|
||||
expect(violations).toMatchInlineSnapshot(`
|
||||
[
|
||||
{
|
||||
"file": "/path/to/test-project/package.json",
|
||||
"message": "The 'typesVersions' entry 'src/utils' maps to 'dist/src/utils.d.ts' but 'exports' maps types to 'dist/src/utils/index.d.ts'. These must match.",
|
||||
"sourceProject": "test-project",
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it('should return a violation for stale typesVersions entry with no matching export', () => {
|
||||
const violations = validateTypesVersionsExportsSync(
|
||||
{
|
||||
typesVersions: {
|
||||
'*': {
|
||||
'src/*': ['dist/src/*.d.ts'],
|
||||
'old-module': ['dist/old-module/index.d.ts'],
|
||||
},
|
||||
},
|
||||
exports: {
|
||||
'./src/*': {
|
||||
types: './dist/src/*.d.ts',
|
||||
default: './dist/src/*.js',
|
||||
},
|
||||
},
|
||||
},
|
||||
SOURCE_PROJECT,
|
||||
PACKAGE_JSON_PATH
|
||||
);
|
||||
expect(violations).toMatchInlineSnapshot(`
|
||||
[
|
||||
{
|
||||
"file": "/path/to/test-project/package.json",
|
||||
"message": "The 'typesVersions' entry 'old-module' has no corresponding 'exports' entry with a 'types' condition. Remove it from 'typesVersions' or add a matching export with a 'types' condition.",
|
||||
"sourceProject": "test-project",
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it('should require separate typesVersions entries for extensionless and extension-bearing subpaths', () => {
|
||||
const violations = validateTypesVersionsExportsSync(
|
||||
{
|
||||
typesVersions: {
|
||||
'*': {
|
||||
'src/*': ['dist/src/*.d.ts'],
|
||||
},
|
||||
},
|
||||
exports: {
|
||||
'./src/*': {
|
||||
types: './dist/src/*.d.ts',
|
||||
default: './dist/src/*.js',
|
||||
},
|
||||
'./src/*.js': {
|
||||
types: './dist/src/*.d.ts',
|
||||
default: './dist/src/*.js',
|
||||
},
|
||||
},
|
||||
},
|
||||
SOURCE_PROJECT,
|
||||
PACKAGE_JSON_PATH
|
||||
);
|
||||
expect(violations).toMatchInlineSnapshot(`
|
||||
[
|
||||
{
|
||||
"file": "/path/to/test-project/package.json",
|
||||
"message": "The exports entry './src/*.js' has a 'types' condition but no corresponding 'typesVersions' entry. Add 'src/*.js' to 'typesVersions' to support node10 module resolution.",
|
||||
"sourceProject": "test-project",
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import {
|
||||
createConformanceRule,
|
||||
type ConformanceViolation,
|
||||
} from '@nx/conformance';
|
||||
import { readJsonFile, workspaceRoot } from '@nx/devkit';
|
||||
import { existsSync } from 'fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
export default createConformanceRule<object>({
|
||||
name: 'types-versions-exports-sync',
|
||||
category: 'consistency',
|
||||
description:
|
||||
'Ensures every "exports" entry with a "types" condition has a matching "typesVersions" entry and vice versa',
|
||||
implementation: async ({ projectGraph }) => {
|
||||
const violations: ConformanceViolation[] = [];
|
||||
|
||||
for (const project of Object.values(projectGraph.nodes)) {
|
||||
const packageJsonPath = join(
|
||||
workspaceRoot,
|
||||
project.data.root,
|
||||
'package.json'
|
||||
);
|
||||
if (existsSync(packageJsonPath)) {
|
||||
const packageJson = readJsonFile(packageJsonPath);
|
||||
violations.push(
|
||||
...validateTypesVersionsExportsSync(
|
||||
packageJson,
|
||||
project.name,
|
||||
packageJsonPath
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
severity: 'medium',
|
||||
details: {
|
||||
violations,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
function normalizePath(p: string): string {
|
||||
return p.replace(/^\.\//, '');
|
||||
}
|
||||
|
||||
export function validateTypesVersionsExportsSync(
|
||||
packageJson: Record<string, unknown>,
|
||||
sourceProject: string,
|
||||
packageJsonPath: string
|
||||
): ConformanceViolation[] {
|
||||
const violations: ConformanceViolation[] = [];
|
||||
|
||||
const typesVersions = packageJson.typesVersions as
|
||||
| Record<string, Record<string, string[]>>
|
||||
| undefined;
|
||||
const exports = packageJson.exports as Record<string, unknown> | undefined;
|
||||
|
||||
if (!typesVersions || !exports) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const tvMap = typesVersions['*'];
|
||||
if (!tvMap) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Check 1: exports entries with "types" that are missing from typesVersions
|
||||
for (const [exportKey, exportValue] of Object.entries(exports)) {
|
||||
if (typeof exportValue === 'string') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof exportValue !== 'object' || exportValue === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const typesPath = (exportValue as Record<string, string>)['types'];
|
||||
if (!typesPath) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip the root "." entry — typesVersions doesn't map the root
|
||||
if (exportKey === '.') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const subpath = normalizePath(exportKey);
|
||||
const normalizedTypesPath = normalizePath(typesPath);
|
||||
|
||||
const tvEntry = tvMap[subpath];
|
||||
if (!tvEntry) {
|
||||
violations.push({
|
||||
message: `The exports entry './${subpath}' has a 'types' condition but no corresponding 'typesVersions' entry. Add '${subpath}' to 'typesVersions' to support node10 module resolution.`,
|
||||
sourceProject,
|
||||
file: packageJsonPath,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const tvPath = tvEntry[0];
|
||||
if (tvPath !== normalizedTypesPath) {
|
||||
violations.push({
|
||||
message: `The 'typesVersions' entry '${subpath}' maps to '${tvPath}' but 'exports' maps types to '${normalizedTypesPath}'. These must match.`,
|
||||
sourceProject,
|
||||
file: packageJsonPath,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check 2: stale typesVersions entries with no matching export
|
||||
for (const tvKey of Object.keys(tvMap)) {
|
||||
const exportKey = `./${tvKey}`;
|
||||
const exportValue = exports[exportKey];
|
||||
|
||||
if (
|
||||
exportValue &&
|
||||
typeof exportValue === 'object' &&
|
||||
(exportValue as Record<string, string>)['types']
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
violations.push({
|
||||
message: `The 'typesVersions' entry '${tvKey}' has no corresponding 'exports' entry with a 'types' condition. Remove it from 'typesVersions' or add a matching export with a 'types' condition.`,
|
||||
sourceProject,
|
||||
file: packageJsonPath,
|
||||
});
|
||||
}
|
||||
|
||||
return violations;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { logger, workspaceRoot } from '@nx/devkit';
|
||||
import type { ExecutorContext } from '@nx/devkit';
|
||||
import { CopyAssetsHandler } from '@nx/js/src/utils/assets/copy-assets-handler';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
|
||||
export interface CopyAssetsExecutorOptions {
|
||||
assets: Array<
|
||||
| string
|
||||
| {
|
||||
input: string;
|
||||
glob: string;
|
||||
output: string;
|
||||
ignore?: string[];
|
||||
includeIgnoredFiles?: boolean;
|
||||
}
|
||||
>;
|
||||
outputPath: string;
|
||||
}
|
||||
|
||||
export async function copyAssetsExecutor(
|
||||
options: CopyAssetsExecutorOptions,
|
||||
context: ExecutorContext
|
||||
): Promise<{ success: boolean }> {
|
||||
const projectName = context.projectName;
|
||||
const projectRoot =
|
||||
context.projectsConfigurations?.projects[projectName]?.root || projectName;
|
||||
|
||||
let outputPath = options.outputPath;
|
||||
|
||||
// Resolve output path relative to project root
|
||||
if (!path.isAbsolute(outputPath)) {
|
||||
outputPath = path.resolve(
|
||||
workspaceRoot,
|
||||
projectRoot,
|
||||
outputPath.startsWith(projectRoot)
|
||||
? path.relative(projectRoot, outputPath)
|
||||
: outputPath
|
||||
);
|
||||
}
|
||||
|
||||
// Ensure output directory exists
|
||||
if (!fs.existsSync(outputPath)) {
|
||||
fs.mkdirSync(outputPath, { recursive: true });
|
||||
}
|
||||
|
||||
const projectDir = path.join(workspaceRoot, projectRoot);
|
||||
|
||||
const assetHandler = new CopyAssetsHandler({
|
||||
projectDir,
|
||||
rootDir: workspaceRoot,
|
||||
outputDir: outputPath,
|
||||
assets: options.assets,
|
||||
});
|
||||
|
||||
try {
|
||||
await assetHandler.processAllAssetsOnce();
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Error processing assets: ${error instanceof Error ? error.message : error}`
|
||||
);
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
logger.info(`✓ Assets copied for ${projectName}`);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export default copyAssetsExecutor;
|
||||
@@ -0,0 +1,13 @@
|
||||
export interface CopyAssetsExecutorSchema {
|
||||
assets: Array<
|
||||
| string
|
||||
| {
|
||||
input: string;
|
||||
glob: string;
|
||||
output: string;
|
||||
ignore?: string[];
|
||||
includeIgnoredFiles?: boolean;
|
||||
}
|
||||
>;
|
||||
outputPath: string;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/schema",
|
||||
"$id": "CopyAssets",
|
||||
"type": "object",
|
||||
"title": "Copy Assets Executor",
|
||||
"description": "Copies assets to the output directory.",
|
||||
"required": ["assets", "outputPath"],
|
||||
"properties": {
|
||||
"assets": {
|
||||
"type": "array",
|
||||
"description": "List of files and globs to copy to the output directory.",
|
||||
"items": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"input": {
|
||||
"type": "string",
|
||||
"description": "The input directory path."
|
||||
},
|
||||
"glob": {
|
||||
"type": "string",
|
||||
"description": "Glob pattern to match files within the input directory."
|
||||
},
|
||||
"output": {
|
||||
"type": "string",
|
||||
"description": "The output path relative to the project's output directory."
|
||||
},
|
||||
"ignore": {
|
||||
"type": "array",
|
||||
"description": "List of glob patterns to exclude from copying.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"includeIgnoredFiles": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to include files that are gitignored."
|
||||
}
|
||||
},
|
||||
"required": ["input", "glob", "output"]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"outputPath": {
|
||||
"type": "string",
|
||||
"description": "The output path for copied assets, relative to the project root."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import { Tree, readJson } from '@nx/devkit';
|
||||
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
|
||||
import generator from './generator';
|
||||
|
||||
describe('bump-maven-version generator', () => {
|
||||
let tree: Tree;
|
||||
|
||||
const mockPomXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0">
|
||||
<groupId>dev.nx</groupId>
|
||||
<artifactId>nx-parent</artifactId>
|
||||
<version>0.0.8</version>
|
||||
</project>`;
|
||||
|
||||
const mockMavenParentPomXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0">
|
||||
<parent>
|
||||
<groupId>dev.nx</groupId>
|
||||
<artifactId>nx-parent</artifactId>
|
||||
<version>0.0.8</version>
|
||||
</parent>
|
||||
<groupId>dev.nx.maven</groupId>
|
||||
<artifactId>nx-maven-parent</artifactId>
|
||||
<packaging>pom</packaging>
|
||||
</project>`;
|
||||
|
||||
const mockMavenPluginPomXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0">
|
||||
<parent>
|
||||
<groupId>dev.nx.maven</groupId>
|
||||
<artifactId>nx-maven-parent</artifactId>
|
||||
<version>0.0.8</version>
|
||||
</parent>
|
||||
<groupId>dev.nx.maven</groupId>
|
||||
<artifactId>nx-maven-plugin</artifactId>
|
||||
<version>\${project.parent.version}</version>
|
||||
</project>`;
|
||||
|
||||
const mockSharedPomXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0">
|
||||
<parent>
|
||||
<groupId>dev.nx.maven</groupId>
|
||||
<artifactId>nx-maven-parent</artifactId>
|
||||
<version>0.0.8</version>
|
||||
</parent>
|
||||
<groupId>dev.nx.maven</groupId>
|
||||
<artifactId>nx-maven-shared</artifactId>
|
||||
</project>`;
|
||||
|
||||
const mockBatchRunnerPomXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0">
|
||||
<parent>
|
||||
<groupId>dev.nx.maven</groupId>
|
||||
<artifactId>nx-maven-parent</artifactId>
|
||||
<version>0.0.8</version>
|
||||
</parent>
|
||||
<groupId>dev.nx.maven</groupId>
|
||||
<artifactId>maven-batch-runner</artifactId>
|
||||
</project>`;
|
||||
|
||||
const mockBatchRunnerAdaptersPomXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0">
|
||||
<parent>
|
||||
<groupId>dev.nx.maven</groupId>
|
||||
<artifactId>nx-maven-parent</artifactId>
|
||||
<version>0.0.8</version>
|
||||
</parent>
|
||||
<artifactId>batch-runner-adapters</artifactId>
|
||||
<packaging>pom</packaging>
|
||||
</project>`;
|
||||
|
||||
const mockMaven3AdapterPomXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0">
|
||||
<parent>
|
||||
<groupId>dev.nx.maven</groupId>
|
||||
<artifactId>batch-runner-adapters</artifactId>
|
||||
<version>0.0.8</version>
|
||||
</parent>
|
||||
<artifactId>maven3-adapter</artifactId>
|
||||
</project>`;
|
||||
|
||||
const mockMaven4AdapterPomXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0">
|
||||
<parent>
|
||||
<groupId>dev.nx.maven</groupId>
|
||||
<artifactId>batch-runner-adapters</artifactId>
|
||||
<version>0.0.8</version>
|
||||
</parent>
|
||||
<artifactId>maven4-adapter</artifactId>
|
||||
</project>`;
|
||||
|
||||
const mockVersionsTs = `export const nxVersion = require('../../package.json').version;
|
||||
export const mavenPluginVersion = '0.0.8';`;
|
||||
|
||||
const mockMigrationsJson = {
|
||||
$schema: '../../node_modules/nx/schemas/generators-schema.json',
|
||||
generators: {
|
||||
'update-0.0.8': {
|
||||
cli: 'nx',
|
||||
version: '22.1.0-beta.4',
|
||||
description:
|
||||
'Update Maven plugin version from 0.0.7 to 0.0.8 in pom.xml files',
|
||||
factory: './dist/migrations/0-0-8/update-pom-xml-version',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
tree = createTreeWithEmptyWorkspace();
|
||||
|
||||
// Setup mock files
|
||||
tree.write('pom.xml', mockPomXml);
|
||||
tree.write('packages/maven/pom.xml', mockMavenParentPomXml);
|
||||
tree.write('packages/maven/maven-plugin/pom.xml', mockMavenPluginPomXml);
|
||||
tree.write('packages/maven/shared/pom.xml', mockSharedPomXml);
|
||||
tree.write('packages/maven/batch-runner/pom.xml', mockBatchRunnerPomXml);
|
||||
tree.write(
|
||||
'packages/maven/batch-runner-adapters/pom.xml',
|
||||
mockBatchRunnerAdaptersPomXml
|
||||
);
|
||||
tree.write(
|
||||
'packages/maven/batch-runner-adapters/maven3/pom.xml',
|
||||
mockMaven3AdapterPomXml
|
||||
);
|
||||
tree.write(
|
||||
'packages/maven/batch-runner-adapters/maven4/pom.xml',
|
||||
mockMaven4AdapterPomXml
|
||||
);
|
||||
tree.write('packages/maven/src/utils/versions.ts', mockVersionsTs);
|
||||
tree.write(
|
||||
'packages/maven/migrations.json',
|
||||
JSON.stringify(mockMigrationsJson)
|
||||
);
|
||||
});
|
||||
|
||||
it('should update root pom.xml version', async () => {
|
||||
await generator(tree, {
|
||||
newVersion: '0.0.9',
|
||||
nxVersion: '22.1.0-beta.6',
|
||||
});
|
||||
|
||||
const updatedPom = tree.read('pom.xml', 'utf-8');
|
||||
expect(updatedPom).toContain('<version>0.0.9</version>');
|
||||
});
|
||||
|
||||
it('should update maven-plugin pom.xml parent version', async () => {
|
||||
await generator(tree, {
|
||||
newVersion: '0.0.9',
|
||||
nxVersion: '22.1.0-beta.6',
|
||||
});
|
||||
|
||||
const updatedPom = tree.read(
|
||||
'packages/maven/maven-plugin/pom.xml',
|
||||
'utf-8'
|
||||
);
|
||||
expect(updatedPom).toContain('<version>0.0.9</version>');
|
||||
});
|
||||
|
||||
it('should update versions.ts mavenPluginVersion', async () => {
|
||||
await generator(tree, {
|
||||
newVersion: '0.0.9',
|
||||
nxVersion: '22.1.0-beta.6',
|
||||
});
|
||||
|
||||
const updatedVersions = tree.read(
|
||||
'packages/maven/src/utils/versions.ts',
|
||||
'utf-8'
|
||||
);
|
||||
expect(updatedVersions).toContain("mavenPluginVersion = '0.0.9'");
|
||||
});
|
||||
|
||||
it('should add migration entry to migrations.json', async () => {
|
||||
await generator(tree, {
|
||||
newVersion: '0.0.9',
|
||||
nxVersion: '22.1.0-beta.6',
|
||||
});
|
||||
|
||||
const migrationsJson = readJson(tree, 'packages/maven/migrations.json');
|
||||
expect(migrationsJson.generators['update-0-0-9']).toBeDefined();
|
||||
expect(migrationsJson.generators['update-0-0-9'].version).toBe(
|
||||
'22.1.0-beta.6'
|
||||
);
|
||||
expect(migrationsJson.generators['update-0-0-9'].factory).toContain(
|
||||
'0-0-9'
|
||||
);
|
||||
});
|
||||
|
||||
it('should create migration file with correct content', async () => {
|
||||
await generator(tree, {
|
||||
newVersion: '0.0.9',
|
||||
nxVersion: '22.1.0-beta.6',
|
||||
});
|
||||
|
||||
const migrationFile = tree.read(
|
||||
'packages/maven/src/migrations/0-0-9/update-pom-xml-version.ts',
|
||||
'utf-8'
|
||||
);
|
||||
expect(migrationFile).toMatchInlineSnapshot(`
|
||||
"import { Tree } from '@nx/devkit';
|
||||
import { updateNxMavenPluginVersion } from '../../utils/pom-xml-updater';
|
||||
|
||||
/**
|
||||
* Migration for @nx/maven v0.0.9
|
||||
* Updates the Maven plugin version to 0.0.9 in pom.xml files
|
||||
*/
|
||||
export default async function update(tree: Tree) {
|
||||
// Update user pom.xml files
|
||||
updateNxMavenPluginVersion(tree, '0.0.9');
|
||||
}
|
||||
"
|
||||
`);
|
||||
});
|
||||
|
||||
it('should handle version format correctly for different versions', async () => {
|
||||
await generator(tree, {
|
||||
newVersion: '0.0.10',
|
||||
nxVersion: '22.1.0-beta.7',
|
||||
});
|
||||
|
||||
const migrationFile = tree.read(
|
||||
'packages/maven/src/migrations/0-0-10/update-pom-xml-version.ts',
|
||||
'utf-8'
|
||||
);
|
||||
expect(migrationFile).toContain("'0.0.10'");
|
||||
});
|
||||
|
||||
it('should reject invalid version format', async () => {
|
||||
expect(async () => {
|
||||
await generator(tree, {
|
||||
newVersion: '0.0',
|
||||
nxVersion: '22.1.0-beta.6',
|
||||
});
|
||||
}).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,251 @@
|
||||
import { formatFiles, logger, readJson, updateJson } from '@nx/devkit';
|
||||
import type { Tree } from '@nx/devkit';
|
||||
import { DOMParser, XMLSerializer } from '@xmldom/xmldom';
|
||||
import * as path from 'path';
|
||||
|
||||
interface BumpMavenVersionSchema {
|
||||
newVersion: string;
|
||||
nxVersion: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a semantic version string into components
|
||||
* @example "0.0.10" -> { major: "0", minor: "0", patch: "10" }
|
||||
*/
|
||||
function parseVersion(version: string): {
|
||||
major: string;
|
||||
minor: string;
|
||||
patch: string;
|
||||
} {
|
||||
const parts = version.split('.');
|
||||
if (parts.length !== 3) {
|
||||
throw new Error(
|
||||
`Invalid version format: ${version}. Expected X.Y.Z format.`
|
||||
);
|
||||
}
|
||||
return {
|
||||
major: parts[0],
|
||||
minor: parts[1],
|
||||
patch: parts[2],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts version X.Y.Z to directory format X-Y-Z
|
||||
*/
|
||||
function versionToDirFormat(version: string): string {
|
||||
return version.replace(/\./g, '-');
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates an XML file's version element
|
||||
*/
|
||||
function updateXmlVersion(
|
||||
tree: Tree,
|
||||
filePath: string,
|
||||
newVersion: string,
|
||||
isRoot: boolean
|
||||
): void {
|
||||
const content = tree.read(filePath, 'utf-8');
|
||||
if (!content) {
|
||||
throw new Error(`File not found: ${filePath}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(content);
|
||||
|
||||
// Find the version element(s) and update them
|
||||
let updated = false;
|
||||
|
||||
// For root pom.xml, update <version> directly under <project>
|
||||
if (isRoot) {
|
||||
const projectElement = doc.documentElement;
|
||||
if (projectElement.tagName === 'project') {
|
||||
const versionElements = projectElement.getElementsByTagName('version');
|
||||
|
||||
// Update the first version element (direct child of project)
|
||||
for (let i = 0; i < versionElements.length; i++) {
|
||||
const versionElement = versionElements.item(i);
|
||||
if (versionElement && versionElement.parentNode === projectElement) {
|
||||
versionElement.textContent = newVersion;
|
||||
updated = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const parentElement = doc.getElementsByTagName('parent').item(0);
|
||||
if (parentElement) {
|
||||
const versionElements = parentElement.getElementsByTagName('version');
|
||||
|
||||
// Update the first version element within parent
|
||||
for (let i = 0; i < versionElements.length; i++) {
|
||||
const versionElement = versionElements.item(i);
|
||||
if (versionElement) {
|
||||
versionElement.textContent = newVersion;
|
||||
updated = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!updated) {
|
||||
throw new Error(`Could not find version element in ${filePath}`);
|
||||
}
|
||||
|
||||
const serializer = new XMLSerializer();
|
||||
const updatedContent = serializer.serializeToString(doc);
|
||||
tree.write(filePath, updatedContent);
|
||||
logger.info(`Updated version in ${filePath} to ${newVersion}`);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to update XML in ${filePath}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export default async function runGenerator(
|
||||
tree: Tree,
|
||||
options: BumpMavenVersionSchema
|
||||
) {
|
||||
const { newVersion, nxVersion } = options;
|
||||
|
||||
// Validate version format
|
||||
parseVersion(newVersion);
|
||||
|
||||
const dirFormat = versionToDirFormat(newVersion);
|
||||
const migrationsDir = `packages/maven/src/migrations/${dirFormat}`;
|
||||
|
||||
logger.info(
|
||||
`Bumping Maven plugin version to ${newVersion} for Nx ${nxVersion}`
|
||||
);
|
||||
|
||||
try {
|
||||
// 1. Update root pom.xml
|
||||
logger.info('Updating /pom.xml...');
|
||||
updateXmlVersion(tree, 'pom.xml', newVersion, true);
|
||||
|
||||
// 2. Update packages/maven/pom.xml
|
||||
logger.info('Updating packages/maven/pom.xml...');
|
||||
updateXmlVersion(tree, 'packages/maven/pom.xml', newVersion, false);
|
||||
|
||||
// 3. Update maven-plugin pom.xml
|
||||
logger.info('Updating packages/maven/maven-plugin/pom.xml...');
|
||||
updateXmlVersion(
|
||||
tree,
|
||||
'packages/maven/maven-plugin/pom.xml',
|
||||
newVersion,
|
||||
false
|
||||
);
|
||||
|
||||
// 4. Update shared pom.xml
|
||||
logger.info('Updating packages/maven/shared/pom.xml...');
|
||||
updateXmlVersion(tree, 'packages/maven/shared/pom.xml', newVersion, false);
|
||||
|
||||
// 5. Update batch-runner pom.xml
|
||||
logger.info('Updating packages/maven/batch-runner/pom.xml...');
|
||||
updateXmlVersion(
|
||||
tree,
|
||||
'packages/maven/batch-runner/pom.xml',
|
||||
newVersion,
|
||||
false
|
||||
);
|
||||
|
||||
// 5b. Update batch-runner-adapters pom.xml files
|
||||
logger.info('Updating packages/maven/batch-runner-adapters/pom.xml...');
|
||||
updateXmlVersion(
|
||||
tree,
|
||||
'packages/maven/batch-runner-adapters/pom.xml',
|
||||
newVersion,
|
||||
false
|
||||
);
|
||||
|
||||
logger.info(
|
||||
'Updating packages/maven/batch-runner-adapters/maven3/pom.xml...'
|
||||
);
|
||||
updateXmlVersion(
|
||||
tree,
|
||||
'packages/maven/batch-runner-adapters/maven3/pom.xml',
|
||||
newVersion,
|
||||
false
|
||||
);
|
||||
|
||||
logger.info(
|
||||
'Updating packages/maven/batch-runner-adapters/maven4/pom.xml...'
|
||||
);
|
||||
updateXmlVersion(
|
||||
tree,
|
||||
'packages/maven/batch-runner-adapters/maven4/pom.xml',
|
||||
newVersion,
|
||||
false
|
||||
);
|
||||
|
||||
// 6. Update versions.ts
|
||||
logger.info('Updating packages/maven/src/utils/versions.ts...');
|
||||
const versionsFile = tree.read(
|
||||
'packages/maven/src/utils/versions.ts',
|
||||
'utf-8'
|
||||
);
|
||||
if (!versionsFile) {
|
||||
throw new Error('packages/maven/src/utils/versions.ts not found');
|
||||
}
|
||||
|
||||
const updatedVersionsFile = versionsFile.replace(
|
||||
/export const mavenPluginVersion = '[^']*';/,
|
||||
`export const mavenPluginVersion = '${newVersion}';`
|
||||
);
|
||||
|
||||
tree.write('packages/maven/src/utils/versions.ts', updatedVersionsFile);
|
||||
|
||||
// 7. Update migrations.json
|
||||
logger.info('Updating packages/maven/migrations.json...');
|
||||
const migrationsJsonPath = 'packages/maven/migrations.json';
|
||||
const migrationEntry = {
|
||||
[migrationsJsonPath]: (current: any) => {
|
||||
const migrationKey = `update-${dirFormat}`;
|
||||
return {
|
||||
...current,
|
||||
generators: {
|
||||
...current.generators,
|
||||
[migrationKey]: {
|
||||
cli: 'nx',
|
||||
version: nxVersion,
|
||||
description: `Update Maven plugin version from 0.0.${
|
||||
parseInt(newVersion.split('.')[2]) - 1
|
||||
} to ${newVersion} in pom.xml files`,
|
||||
factory: `./dist/migrations/${dirFormat}/update-pom-xml-version`,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
updateJson(tree, migrationsJsonPath, migrationEntry[migrationsJsonPath]);
|
||||
|
||||
// 8. Create migration file
|
||||
logger.info(`Creating migration file in ${migrationsDir}...`);
|
||||
const migrationContent = `import { Tree } from '@nx/devkit';
|
||||
import { updateNxMavenPluginVersion } from '../../utils/pom-xml-updater';
|
||||
|
||||
/**
|
||||
* Migration for @nx/maven v${newVersion}
|
||||
* Updates the Maven plugin version to ${newVersion} in pom.xml files
|
||||
*/
|
||||
export default async function update(tree: Tree) {
|
||||
// Update user pom.xml files
|
||||
updateNxMavenPluginVersion(tree, '${newVersion}');
|
||||
}
|
||||
`;
|
||||
|
||||
tree.write(`${migrationsDir}/update-pom-xml-version.ts`, migrationContent);
|
||||
|
||||
logger.info('Formatting files...');
|
||||
await formatFiles(tree);
|
||||
|
||||
logger.info(`✅ Successfully bumped Maven plugin version to ${newVersion}`);
|
||||
logger.info(` Migration created for Nx ${nxVersion}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to bump Maven version: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/schema",
|
||||
"type": "object",
|
||||
"description": "Bump the Maven plugin version and create a migration for users.",
|
||||
"properties": {
|
||||
"newVersion": {
|
||||
"type": "string",
|
||||
"description": "The version to which to bump maven to. New Maven plugin version (e.g., '0.0.10')",
|
||||
"pattern": "^\\d+\\.\\d+\\.\\d+$"
|
||||
},
|
||||
"nxVersion": {
|
||||
"type": "string",
|
||||
"description": "Nx version for the migration (e.g., '22.1.0-beta.7'). To find a good value for this, use 'npm view nx@next' and choose the next prerelease version which will follow that. For example, if the next version of `nx` is 22.1.0-beta.2, then use 22.1.0-beta.3"
|
||||
}
|
||||
},
|
||||
"required": ["newVersion", "nxVersion"],
|
||||
"examples": [
|
||||
{
|
||||
"newVersion": "0.0.10",
|
||||
"nxVersion": "22.1.0-beta.7"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
|
||||
import {
|
||||
addProjectConfiguration,
|
||||
Tree,
|
||||
workspaceRoot,
|
||||
writeJson,
|
||||
} from '@nx/devkit';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import update from './generator';
|
||||
|
||||
describe('remove-migrations generator', () => {
|
||||
let tree: Tree;
|
||||
|
||||
beforeEach(() => {
|
||||
tree = createTreeWithEmptyWorkspace();
|
||||
|
||||
addProjectConfiguration(tree, 'js', {
|
||||
root: 'packages/js',
|
||||
targets: {
|
||||
build: {},
|
||||
},
|
||||
});
|
||||
|
||||
jest.spyOn(process, 'cwd').mockReturnValue('/virtual/packages/js');
|
||||
|
||||
process.env.INIT_CWD = join(workspaceRoot, 'packages/js');
|
||||
});
|
||||
|
||||
it('should remove migrations older than specified version', async () => {
|
||||
writeJson(tree, 'packages/js/package.json', {
|
||||
'nx-migrations': {
|
||||
migrations: './migrations.json',
|
||||
},
|
||||
});
|
||||
writeJson(tree, 'packages/js/migrations.json', {
|
||||
generators: {
|
||||
'update-99-0-0-remove': {
|
||||
cli: 'nx',
|
||||
version: '99.0.0-beta.0',
|
||||
implementation: './src/migrations/update-99-0-0/remove',
|
||||
},
|
||||
'update-100-0-0-keep': {
|
||||
cli: 'nx',
|
||||
version: '100.0.0-beta.0',
|
||||
implementation: './src/migrations/update-100-0-0/keep',
|
||||
},
|
||||
},
|
||||
});
|
||||
tree.write(
|
||||
'packages/js/src/migrations/update-99-0-0/__snapshots__/remove.spec.ts.snap',
|
||||
''
|
||||
);
|
||||
tree.write('packages/js/src/migrations/update-99-0-0/helpers.ts', '');
|
||||
tree.write('packages/js/src/migrations/update-99-0-0/remove.ts', '');
|
||||
tree.write('packages/js/src/migrations/update-99-0-0/remove.spec.ts', '');
|
||||
tree.write(
|
||||
'packages/js/src/migrations/update-100-0-0/__snapshots__/keep.spec.ts.snap',
|
||||
''
|
||||
);
|
||||
tree.write('packages/js/src/migrations/update-100-0-0/keep.spec.ts', '');
|
||||
tree.write('packages/js/src/migrations/update-100-0-0/keep.ts', '');
|
||||
|
||||
await update(tree, { v: 100 });
|
||||
|
||||
expect(
|
||||
tree.exists(
|
||||
'packages/js/src/migrations/update-99-0-0/__snapshots__/remove.spec.ts.snap'
|
||||
)
|
||||
).toBeFalsy();
|
||||
expect(
|
||||
tree.exists('packages/js/src/migrations/update-99-0-0/remove.ts')
|
||||
).toBeFalsy();
|
||||
expect(
|
||||
tree.exists('packages/js/src/migrations/update-99-0-0/helpers.ts')
|
||||
).toBeFalsy();
|
||||
expect(
|
||||
tree.exists('packages/js/src/migrations/update-99-0-0/remove.spec.ts')
|
||||
).toBeFalsy();
|
||||
expect(
|
||||
tree.exists(
|
||||
'packages/js/src/migrations/update-100-0-0/__snapshots__/keep.spec.ts.snap'
|
||||
)
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
tree.exists('packages/js/src/migrations/update-100-0-0/keep.spec.ts')
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
tree.exists('packages/js/src/migrations/update-100-0-0/keep.ts')
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import {
|
||||
formatFiles,
|
||||
readJson,
|
||||
updateJson,
|
||||
visitNotIgnoredFiles,
|
||||
} from '@nx/devkit';
|
||||
import type { Tree } from '@nx/devkit';
|
||||
import { basename, dirname, join } from 'path';
|
||||
import { major } from 'semver';
|
||||
|
||||
export interface RemoveMigrationsGeneratorSchema {
|
||||
v: number;
|
||||
}
|
||||
|
||||
export async function removeMigrationsGenerator(
|
||||
tree: Tree,
|
||||
{ v }: RemoveMigrationsGeneratorSchema
|
||||
) {
|
||||
visitNotIgnoredFiles(tree, '', (path) => {
|
||||
// Ignore nx migrations because they are needed for nx repair
|
||||
if (['packages/nx/package.json'].includes(path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ignore angular migrations because angular needs to support migrations until LTS support is dropped
|
||||
if (['packages/angular/package.json'].includes(path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (basename(path) !== 'package.json') {
|
||||
return;
|
||||
}
|
||||
|
||||
const packageJson = readJson(tree, path);
|
||||
|
||||
const migrations =
|
||||
packageJson?.['nx-migrations']?.migrations ??
|
||||
packageJson?.['ng-update']?.migrations;
|
||||
if (!migrations) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (migrations.startsWith('.')) {
|
||||
const migrationsPath = join(dirname(path), migrations);
|
||||
|
||||
updateJson(tree, migrationsPath, (migrationsJson) => {
|
||||
const { generators, packageJsonUpdates } = migrationsJson;
|
||||
for (const [migrationName, m] of Object.entries(generators ?? {})) {
|
||||
if (major(m['version']) < v) {
|
||||
const implFile = getImplFile(tree, migrationsPath, m);
|
||||
const dir = dirname(implFile);
|
||||
|
||||
try {
|
||||
tree.delete(implFile);
|
||||
visitNotIgnoredFiles(tree, dir, (f) => tree.delete(f));
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
|
||||
delete migrationsJson.generators[migrationName];
|
||||
}
|
||||
}
|
||||
|
||||
for (const [updateName, packageJsonUpdate] of Object.entries(
|
||||
packageJsonUpdates ?? {}
|
||||
)) {
|
||||
if (major(packageJsonUpdate['version']) < v) {
|
||||
delete packageJsonUpdates[updateName];
|
||||
}
|
||||
}
|
||||
|
||||
return migrationsJson;
|
||||
});
|
||||
}
|
||||
});
|
||||
await formatFiles(tree);
|
||||
}
|
||||
|
||||
export default removeMigrationsGenerator;
|
||||
|
||||
function getImplFile(
|
||||
tree: Tree,
|
||||
migrationsFilePath: string,
|
||||
{
|
||||
implementation,
|
||||
factory,
|
||||
}: {
|
||||
implementation?: string;
|
||||
factory?: string;
|
||||
}
|
||||
) {
|
||||
const rawPath = implementation ?? factory;
|
||||
|
||||
const fullPath = join(dirname(migrationsFilePath), rawPath);
|
||||
|
||||
if (tree.exists(fullPath)) {
|
||||
return fullPath;
|
||||
}
|
||||
if (tree.exists(fullPath + '.ts')) {
|
||||
return fullPath + '.ts';
|
||||
}
|
||||
|
||||
return fullPath;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/schema",
|
||||
"$id": "RemoveMigrations",
|
||||
"title": "",
|
||||
"type": "object",
|
||||
"description": "A generator for removing old migrations.",
|
||||
"properties": {
|
||||
"v": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": []
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { createNodesFromFiles, readJsonFile } from '@nx/devkit';
|
||||
import type { CreateNodes, TargetConfiguration } from '@nx/devkit';
|
||||
import {
|
||||
getAssetOutputPath,
|
||||
normalizeAssets,
|
||||
} from '@nx/js/src/utils/assets/copy-assets-handler';
|
||||
import { dirname, join, relative } from 'node:path';
|
||||
|
||||
interface AssetEntry {
|
||||
glob: string;
|
||||
input?: string;
|
||||
output?: string;
|
||||
ignore?: string[];
|
||||
includeIgnoredFiles?: boolean;
|
||||
}
|
||||
|
||||
interface AssetsJson {
|
||||
outDir: string;
|
||||
assets: (AssetEntry | string)[];
|
||||
}
|
||||
|
||||
export const createNodes: CreateNodes = [
|
||||
'packages/*/assets.json',
|
||||
async (configFiles, _options, context) => {
|
||||
return await createNodesFromFiles(
|
||||
(configFilePath, _options, context) => {
|
||||
const projectRoot = dirname(configFilePath);
|
||||
const assetsJson = readJsonFile<AssetsJson>(
|
||||
join(context.workspaceRoot, configFilePath)
|
||||
);
|
||||
|
||||
// Separate string assets (passed through as-is) from object assets
|
||||
const objectAssets: AssetEntry[] = [];
|
||||
const stringAssets: string[] = [];
|
||||
for (const asset of assetsJson.assets) {
|
||||
if (typeof asset === 'string') {
|
||||
stringAssets.push(asset);
|
||||
} else {
|
||||
objectAssets.push(asset);
|
||||
}
|
||||
}
|
||||
|
||||
// Build executor assets
|
||||
const executorAssets: (
|
||||
| {
|
||||
input: string;
|
||||
glob: string;
|
||||
ignore?: string[];
|
||||
output: string;
|
||||
includeIgnoredFiles?: boolean;
|
||||
}
|
||||
| string
|
||||
)[] = [
|
||||
...objectAssets.map((asset) => {
|
||||
const input = asset.input ?? projectRoot;
|
||||
const output = asset.output ?? '/';
|
||||
const ignore =
|
||||
input === projectRoot
|
||||
? [
|
||||
`${relative(projectRoot, assetsJson.outDir)}/**`,
|
||||
'assets.json',
|
||||
...(asset.ignore ?? []),
|
||||
]
|
||||
: asset.ignore;
|
||||
return {
|
||||
input,
|
||||
glob: asset.glob,
|
||||
...(ignore?.length ? { ignore } : {}),
|
||||
output,
|
||||
...(asset.includeIgnoredFiles
|
||||
? { includeIgnoredFiles: true }
|
||||
: {}),
|
||||
};
|
||||
}),
|
||||
...stringAssets,
|
||||
];
|
||||
|
||||
// Normalize assets to derive outputs
|
||||
const projectDir = join(context.workspaceRoot, projectRoot);
|
||||
const outputDir = join(context.workspaceRoot, assetsJson.outDir);
|
||||
const normalized = normalizeAssets(
|
||||
executorAssets,
|
||||
context.workspaceRoot,
|
||||
projectDir,
|
||||
outputDir
|
||||
);
|
||||
|
||||
// Derive inputs — positive patterns first, then negations,
|
||||
// then dependentTasksOutputFiles for gitignored assets
|
||||
const positiveInputs = new Set<string>();
|
||||
const negativeInputs = new Set<string>();
|
||||
const dependentOutputGlobs = new Set<string>();
|
||||
for (const asset of objectAssets) {
|
||||
const input = asset.input ?? projectRoot;
|
||||
if (asset.includeIgnoredFiles) {
|
||||
dependentOutputGlobs.add(asset.glob);
|
||||
} else if (input === projectRoot) {
|
||||
positiveInputs.add(`{projectRoot}/${asset.glob}`);
|
||||
if (asset.ignore) {
|
||||
for (const pattern of asset.ignore) {
|
||||
negativeInputs.add(`!{projectRoot}/${pattern}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
positiveInputs.add(`{workspaceRoot}/${input}/${asset.glob}`);
|
||||
}
|
||||
}
|
||||
for (const asset of stringAssets) {
|
||||
positiveInputs.add(`{workspaceRoot}/${asset}`);
|
||||
}
|
||||
const inputs: TargetConfiguration['inputs'] = [
|
||||
...positiveInputs,
|
||||
...negativeInputs,
|
||||
'{workspaceRoot}/tools/workspace-plugin/**/*',
|
||||
];
|
||||
for (const glob of dependentOutputGlobs) {
|
||||
inputs.push({
|
||||
dependentTasksOutputFiles: glob,
|
||||
transitive: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Derive outputs
|
||||
const outputs = normalized.map((entry) => {
|
||||
const outputPath = getAssetOutputPath(entry.pattern, entry);
|
||||
if (outputPath.startsWith(projectRoot + '/')) {
|
||||
return `{projectRoot}/${outputPath.slice(projectRoot.length + 1)}`;
|
||||
}
|
||||
return `{workspaceRoot}/${outputPath}`;
|
||||
});
|
||||
|
||||
const target: TargetConfiguration = {
|
||||
executor: '@nx/workspace-plugin:copy-assets',
|
||||
cache: true,
|
||||
inputs,
|
||||
outputs,
|
||||
options: {
|
||||
outputPath: relative(projectRoot, assetsJson.outDir),
|
||||
assets: executorAssets,
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
projects: {
|
||||
[projectRoot]: {
|
||||
targets: {
|
||||
'copy-assets': target,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
configFiles,
|
||||
_options,
|
||||
context
|
||||
);
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
],
|
||||
"nx": {
|
||||
"addTypecheckTarget": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"declaration": true,
|
||||
"types": ["node"],
|
||||
"composite": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"verbatimModuleSyntax": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.json"],
|
||||
"exclude": [
|
||||
"jest.config.ts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/files/**"
|
||||
],
|
||||
"references": []
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist/spec",
|
||||
"types": ["jest", "node"],
|
||||
"composite": true
|
||||
},
|
||||
"include": [
|
||||
"jest.config.ts",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.d.ts"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../packages/nx"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/plugin"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/eslint"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user