chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:38:36 +08:00
commit 8e2a6eb840
10194 changed files with 1593658 additions and 0 deletions
@@ -0,0 +1,3 @@
import { baseConfig } from '../../../eslint.config.mjs';
export default [...baseConfig];
@@ -0,0 +1,17 @@
/* eslint-disable */
module.exports = {
displayName: 'tools-documentation-create-embeddings',
preset: './jest.preset.js',
transform: {
'^.+\\.(ts|tsx|js|jsx|mts|mjs)$': [
'ts-jest',
{ tsconfig: '<rootDir>/tsconfig.spec.json' },
],
},
transformIgnorePatterns: [
// Ensure that Jest does not ignore github-slugger
'<rootDir>/node_modules/.pnpm/(?!(github-slugger)@)',
],
moduleFileExtensions: ['mts', 'ts', 'js', 'html'],
coverageDirectory: '../../../coverage/tools/documentation/create-embeddings',
};
@@ -0,0 +1,14 @@
const nxPreset = require('@nx/jest/preset').default;
module.exports = {
...nxPreset,
testTimeout: 30000,
testMatch: ['**/+(*.)+(spec|test).+(ts|js)?(x)'],
transform: {
'^.+\\.(ts|tsx|js|jsx|mts|mjs)$': 'ts-jest',
},
resolver: '../../../scripts/patched-jest-resolver.js',
moduleFileExtensions: ['ts', 'js', 'mts', 'html'],
coverageReporters: ['html'],
maxWorkers: 1,
};
@@ -0,0 +1,26 @@
{
"name": "tools-documentation-create-embeddings",
"version": "0.0.1",
"private": true,
"dependencies": {
"@supabase/supabase-js": "*",
"dotenv": "*",
"dotenv-expand": "*",
"openai": "*",
"yargs": "*",
"github-slugger": "*",
"mdast-util-from-markdown": "*",
"mdast-util-to-markdown": "*",
"mdast-util-to-string": "*",
"rehype-parse": "^9.0.0",
"rehype-remark": "^10.0.0",
"remark-stringify": "^11.0.0",
"remark-gfm": "^4.0.0",
"unified": "^11.0.4",
"unist-builder": "*"
},
"devDependencies": {
"@types/yargs": "*",
"typescript": "catalog:typescript"
}
}
@@ -0,0 +1,58 @@
{
"name": "tools-documentation-create-embeddings",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "tools/documentation/create-embeddings/src",
"projectType": "application",
"targets": {
"build": {
"executor": "@nx/esbuild:esbuild",
"outputs": ["{options.outputPath}"],
"defaultConfiguration": "production",
"options": {
"platform": "node",
"outputPath": "tools/documentation/create-embeddings/dist",
"format": ["esm"],
"bundle": true,
"main": "tools/documentation/create-embeddings/src/main.mts",
"tsConfig": "tools/documentation/create-embeddings/tsconfig.app.json",
"assets": ["tools/documentation/create-embeddings/src/assets"],
"esbuildOptions": {
"sourcemap": true,
"outExtension": {
".js": ".js"
}
},
"external": ["fs", "path", "crypto"],
"skipTypeCheck": true
},
"configurations": {
"development": {},
"production": {
"esbuildOptions": {
"sourcemap": false,
"outExtension": {
".js": ".js"
}
}
}
}
},
"run-node": {
"executor": "@nx/js:node",
"defaultConfiguration": "development",
"options": {
"buildTarget": "tools-documentation-create-embeddings:build",
"watch": false
},
"configurations": {
"development": {
"buildTarget": "tools-documentation-create-embeddings:build:development"
},
"production": {
"buildTarget": "tools-documentation-create-embeddings:build:production"
}
}
}
},
"tags": []
}
@@ -0,0 +1,730 @@
// based on:
// https://github.com/supabase-community/nextjs-openai-doc-search/blob/main/lib/generate-embeddings.ts
import { createClient } from '@supabase/supabase-js';
import { config as loadDotEnvFile } from 'dotenv';
import { expand } from 'dotenv-expand';
import { readFile, readdir } from 'fs/promises';
import 'openai';
import OpenAI from 'openai';
import yargs from 'yargs';
import { createHash } from 'crypto';
import GithubSlugger from 'github-slugger';
import { fromMarkdown } from 'mdast-util-from-markdown';
import { toMarkdown } from 'mdast-util-to-markdown';
import { toString } from 'mdast-util-to-string';
import { u } from 'unist-builder';
import { join, relative } from 'path';
import { unified } from 'unified';
import rehypeParse from 'rehype-parse';
import rehypeRemark from 'rehype-remark';
import remarkStringify from 'remark-stringify';
import remarkGfm from 'remark-gfm';
import { workspaceRoot } from '@nx/devkit';
import { readFileSync } from 'fs';
let identityMap = {};
const myEnv = loadDotEnvFile();
expand(myEnv);
type ProcessedMdx = {
checksum: string;
sections: Section[];
};
type Section = {
content: string;
heading?: string;
slug?: string;
};
/**
* Splits a `mdast` tree into multiple trees based on
* a predicate function. Will include the splitting node
* at the beginning of each tree.
*
* Useful to split a markdown file into smaller sections.
*/
export function splitTreeBy(tree: any, predicate: (node: any) => boolean) {
return tree.children.reduce((trees: any, node: any) => {
const [lastTree] = trees.slice(-1);
if (!lastTree || predicate(node)) {
const tree = u('root', [node]);
return trees.concat(tree);
}
lastTree.children.push(node);
return trees;
}, []);
}
/**
* Processes MD content for search indexing.
* It extracts metadata and splits it into sub-sections based on criteria.
*/
export function processMdxForSearch(content: string): ProcessedMdx {
const checksum = createHash('sha256').update(content).digest('base64');
const mdTree = fromMarkdown(content, {});
if (!mdTree) {
return {
checksum,
sections: []
};
}
const sectionTrees = splitTreeBy(mdTree, (node) => node.type === 'heading');
const slugger = new GithubSlugger();
const sections = sectionTrees.map((tree: any) => {
const [firstNode] = tree.children;
const heading =
firstNode.type === 'heading'
? removeTitleDescriptionFromHeading(toString(firstNode))
: undefined;
const slug = heading ? slugger.slug(heading) : undefined;
return {
content: toMarkdown(tree),
heading,
slug
};
});
return {
checksum,
sections
};
}
type WalkEntry = {
path: string;
url_partial: string;
};
abstract class BaseEmbeddingSource {
checksum?: string;
sections?: Section[];
constructor(
public source: string,
public path: string,
public url_partial: string
) {
}
abstract load(): Promise<{
checksum: string;
sections: Section[];
}>;
}
class MarkdownEmbeddingSource extends BaseEmbeddingSource {
type: 'markdown' = 'markdown';
constructor(
source: string,
public filePath: string,
public url_partial: string,
public fileContent?: string
) {
let path: string;
// Check if this is an Astro HTML file
if (filePath.includes('astro-docs/dist') && filePath.endsWith('.html')) {
// Get path relative to astro-docs root (e.g., dist/concepts/mental-model/index.html)
const astroDocsIndex = filePath.indexOf('astro-docs/');
if (astroDocsIndex !== -1) {
path = filePath.substring(astroDocsIndex + 'astro-docs/'.length);
} else {
path = filePath;
}
} else {
// Legacy behavior for markdown files
path = filePath.replace(/^docs/, '').replace(/\.md?$/, '');
}
super(source, path, url_partial);
}
async load() {
const contents =
this.fileContent ?? (await readFile(this.filePath, 'utf8'));
let markdown: string;
// Check if this is HTML (Astro mode)
if (this.filePath.endsWith('.html')) {
markdown = await htmlToMarkdown(contents);
} else {
markdown = contents;
}
const { checksum, sections } = processMdxForSearch(markdown);
this.checksum = checksum;
this.sections = sections;
return {
checksum,
sections
};
}
}
type EmbeddingSource = MarkdownEmbeddingSource;
async function generateEmbeddings() {
const argv = await yargs(process.argv)
.option('refresh', {
alias: 'r',
description: 'Refresh data',
type: 'boolean'
})
.option('local', {
alias: 'l',
description: 'Write to local JSON files instead of Supabase',
type: 'boolean'
})
.option('mode', {
alias: 'm',
description: 'Source mode: astro or legacy',
type: 'string',
choices: ['astro', 'legacy'],
default: 'astro'
})
.argv;
const shouldRefresh = argv.refresh;
const isLocal = argv.local;
const sourceMode = argv.mode as 'astro' | 'legacy';
// Skip validation when in local mode
if (!isLocal) {
if (!process.env.NX_NEXT_PUBLIC_SUPABASE_URL) {
throw new Error(
'Environment variable NX_NEXT_PUBLIC_SUPABASE_URL is required: skipping embeddings generation'
);
}
if (!process.env.NX_SUPABASE_SERVICE_ROLE_KEY) {
throw new Error(
'Environment variable NX_SUPABASE_SERVICE_ROLE_KEY is required: skipping embeddings generation'
);
}
if (!process.env.NX_OPENAI_KEY) {
throw new Error(
'Environment variable NX_OPENAI_KEY is required: skipping embeddings generation'
);
}
}
const supabaseClient = !isLocal
? createClient(
process.env.NX_NEXT_PUBLIC_SUPABASE_URL,
process.env.NX_SUPABASE_SERVICE_ROLE_KEY,
{
auth: {
persistSession: false,
autoRefreshToken: false
}
}
)
: null;
// Local storage for JSON output
const localPages: any[] = [];
const localPageSections: any[] = [];
let nextPageId = 1;
let nextSectionId = 1;
const allFilesPaths: WalkEntry[] = sourceMode === 'astro' ? await getAstroPaths() : await getLegacyPaths();
const embeddingSources: EmbeddingSource[] = [
...allFilesPaths.map((entry) => {
return new MarkdownEmbeddingSource(
'guide',
entry.path,
entry.url_partial
);
}),
...(await createMarkdownForCommunityPlugins()).map((content, index) => {
return new MarkdownEmbeddingSource(
'community-plugins',
'/astro-docs/src/content/approved-community-plugins.json#' + index,
content.url,
content.text
);
}),
];
console.log(`Discovered ${embeddingSources.length} pages`);
if (!shouldRefresh) {
console.log('Checking which pages are new or have changed');
} else {
console.log('Refresh flag set, re-generating all pages');
}
for (const [index, embeddingSource] of embeddingSources.entries()) {
const { type, source, path, url_partial } = embeddingSource;
try {
const { checksum, sections } = await embeddingSource.load();
if (isLocal) {
// Local mode: skip queries, just accumulate data
const pageId = nextPageId++;
console.log(
`#${index}: [${path}] Adding ${sections.length} page sections (with embeddings)`
);
console.log(
`${embeddingSources.length - index - 1} pages remaining to process.`
);
// Create page record (will update checksum after all sections succeed)
const pageRecord = {
id: pageId,
checksum: null,
path,
url_partial,
type,
source
};
for (const { slug, heading, content } of sections) {
// OpenAI recommends replacing newlines with spaces for best results (specific to embeddings)
const input = content.replace(/\n/g, ' ');
try {
// For local mode, skip actual embedding generation
// Just create placeholder data with same structure
const longer_heading =
source !== 'community-plugins'
? removeTitleDescriptionFromHeading(
createLongerHeading(heading, url_partial)
)
: heading;
localPageSections.push({
id: nextSectionId++,
page_id: pageId,
slug,
heading:
heading?.length && heading !== null && heading !== 'null'
? heading
: longer_heading,
longer_heading,
content,
url_partial,
token_count: 0, // Placeholder
embedding: [] // Placeholder
});
} catch (err) {
console.error(
`Failed to process section for '${path}' starting with '${input.slice(
0,
40
)}...'`
);
throw err;
}
}
// Set page checksum now that all sections succeeded
pageRecord.checksum = checksum;
localPages.push(pageRecord);
} else {
// Supabase mode: existing logic
// Check for existing page in DB and compare checksums
const { error: fetchPageError, data: existingPage } = await supabaseClient
.from('nods_page')
.select('id, path, checksum')
.filter('path', 'eq', path)
.limit(1)
.maybeSingle();
if (fetchPageError) {
throw fetchPageError;
}
// We use checksum to determine if this page & its sections need to be regenerated
if (!shouldRefresh && existingPage?.checksum === checksum) {
continue;
}
if (existingPage) {
if (!shouldRefresh) {
console.log(
`#${index}: [${path}] Docs have changed, removing old page sections and their embeddings`
);
} else {
console.log(
`#${index}: [${path}] Refresh flag set, removing old page sections and their embeddings`
);
}
const { error: deletePageSectionError } = await supabaseClient
.from('nods_page_section')
.delete()
.filter('page_id', 'eq', existingPage.id);
if (deletePageSectionError) {
throw deletePageSectionError;
}
}
// Create/update page record. Intentionally clear checksum until we
// have successfully generated all page sections.
const { error: upsertPageError, data: page } = await supabaseClient
.from('nods_page')
.upsert(
{
checksum: null,
path,
url_partial,
type,
source
},
{ onConflict: 'path' }
)
.select()
.limit(1)
.single();
if (upsertPageError) {
throw upsertPageError;
}
console.log(
`#${index}: [${path}] Adding ${sections.length} page sections (with embeddings)`
);
console.log(
`${embeddingSources.length - index - 1} pages remaining to process.`
);
for (const { slug, heading, content } of sections) {
// OpenAI recommends replacing newlines with spaces for best results (specific to embeddings)
const input = content.replace(/\n/g, ' ');
try {
const openai = new OpenAI({
apiKey: process.env.NX_OPENAI_KEY
});
const embeddingResponse = await openai.embeddings.create({
model: 'text-embedding-ada-002',
input
});
const [responseData] = embeddingResponse.data;
const longer_heading =
source !== 'community-plugins'
? removeTitleDescriptionFromHeading(
createLongerHeading(heading, url_partial)
)
: heading;
const { error: insertPageSectionError } = await supabaseClient
.from('nods_page_section')
.insert({
page_id: page.id,
slug,
heading:
heading?.length && heading !== null && heading !== 'null'
? heading
: longer_heading,
longer_heading,
content,
url_partial,
token_count: embeddingResponse.usage.total_tokens,
embedding: responseData.embedding
})
.select()
.limit(1)
.single();
if (insertPageSectionError) {
throw insertPageSectionError;
}
// Add delay after each request
await delay(500); // delay of 0.5 second
} catch (err) {
// TODO: decide how to better handle failed embeddings
console.error(
`Failed to generate embeddings for '${path}' page section starting with '${input.slice(
0,
40
)}...'`
);
throw err;
}
}
// Set page checksum so that we know this page was stored successfully
const { error: updatePageError } = await supabaseClient
.from('nods_page')
.update({ checksum })
.filter('id', 'eq', page.id);
if (updatePageError) {
throw updatePageError;
}
}
} catch (err) {
console.error(
`Page '${path}' or one/multiple of its page sections failed to store properly. Page has been marked with null checksum to indicate that it needs to be re-generated.`
);
console.error(err);
}
}
// Write local JSON files if in local mode
if (isLocal) {
const fs = await import('fs/promises');
const path = await import('path');
const tmpDir = path.join(process.cwd(), 'tmp');
await fs.mkdir(tmpDir, { recursive: true });
const pagesFile = path.join(tmpDir, `nods_page_${sourceMode}.json`);
const sectionsFile = path.join(tmpDir, `nods_page_section_${sourceMode}.json`);
await fs.writeFile(pagesFile, JSON.stringify(localPages, null, 2));
await fs.writeFile(sectionsFile, JSON.stringify(localPageSections, null, 2));
console.log(`\nWrote ${localPages.length} pages to ${pagesFile}`);
console.log(`Wrote ${localPageSections.length} sections to ${sectionsFile}`);
}
console.log('Embedding generation complete');
}
function delay(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Recursively find all index.html files in Astro dist directory
*/
async function getAstroPaths(): Promise<WalkEntry[]> {
console.log('Using Astro mode - reading from astro-docs/dist');
const astroDocsRoot = join(workspaceRoot, 'astro-docs');
const files: WalkEntry[] = [];
const distDir = join(astroDocsRoot, 'dist');
async function walkDir(dir: string) {
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) {
await walkDir(fullPath);
} else if (entry.name === 'index.html') {
// Derive URL from directory path
// dist/getting-started/index.html -> /docs/getting-started
// dist/concepts/mental-model/index.html -> /docs/concepts/mental-model
const dirPath = relative(distDir, join(fullPath, '..'));
const urlPath = dirPath === '.' ? '' : '/' + dirPath;
files.push({
path: fullPath, // Full path for reading the file
url_partial: `/docs${urlPath}` || '/docs'
});
}
}
}
await walkDir(distDir);
return files;
}
function getAllFilesFromMapJson(doc): WalkEntry[] {
const files: WalkEntry[] = [];
function traverse(itemList) {
for (const item of itemList) {
if (item.file && item.file.length > 0) {
// we can exclude some docs here, eg. the deprecated ones
// the path is the relative path to the file within the nx repo
// the url_partial is the relative path to the file within the docs site - under nx.dev
files.push({
path: `docs/${item.file}.md`,
url_partial: identityMap[item.id]?.path || ''
});
}
if (item.itemList) {
traverse(item.itemList);
}
}
}
for (const item of doc.content) {
traverse([item]);
}
return files;
}
function getAllFilesWithItemList(data): WalkEntry[] {
const files: WalkEntry[] = [];
function traverse(itemList) {
for (const item of itemList) {
if (item.file && item.file.length > 0) {
// the path is the relative path to the file within the nx repo
// the url_partial is the relative path to the file within the docs site - under nx.dev
files.push({ path: `docs/${item.file}.md`, url_partial: item.path });
if (!identityMap[item.id]) {
identityMap = { ...identityMap, [item.id]: item };
}
}
if (item.itemList) {
traverse(item.itemList);
}
}
}
for (const key in data) {
if (data[key].itemList) {
traverse([data[key]]);
} else {
if (data[key].documents) {
files.push(...getAllFilesWithItemList(data[key].documents));
}
if (data[key].generators) {
files.push(...getAllFilesWithItemList(data[key].generators));
}
if (data[key].executors) {
files.push(...getAllFilesWithItemList(data[key].executors));
}
if (data[key]?.length > 0) {
traverse(data[key]);
}
}
}
return files;
}
function createLongerHeading(
heading?: string | null,
url_partial?: string
): string | undefined {
if (url_partial?.length) {
if (heading?.length && heading !== null && heading !== 'null') {
return `${heading}${` - ${
url_partial.split('/')?.[1]?.[0]?.toUpperCase() +
url_partial.split('/')?.[1]?.slice(1)
}`}`;
} else {
return url_partial
.split('#')[0]
.split('/')
.map((part) =>
part?.length ? part[0]?.toUpperCase() + part.slice(1) + ' - ' : ''
)
.join('')
.slice(0, -3);
}
}
}
async function getLegacyPaths(): Promise<WalkEntry[]> {
throw new Error('Legacy mode is no longer supported, please use --mode=astro');
}
async function createMarkdownForCommunityPlugins(): Promise<{
text: string;
url: string;
}[]> {
const communityPlugins = JSON.parse(readFileSync(
'../../../../astro-docs/src/content/approved-community-plugins.json', 'utf-8'
))
return communityPlugins.map((plugin) => {
return {
text: `## ${plugin.name} plugin\n\nThere is a ${plugin.name} community plugin.\n\nHere is the description for it: ${plugin.description}\n\nHere is the link to it: [${plugin.url}](${plugin.url})\n\nHere is the list of all the plugins that exist for Nx: https://nx.dev/plugin-registry`,
url: plugin.url,
};
});
}
function removeTitleDescriptionFromHeading(
inputString?: string
): string | null {
/**
* Heading node can be like this:
* title: 'Angular Monorepo Tutorial - Part 1: Code Generation'
* description: In this tutorial you'll create a frontend-focused workspace with Nx.
*
* We only want to keep the title part.
*/
if (!inputString) {
return null;
}
const titleMatch = inputString.match(/title:\s*(.+?)(?=\s*description:)/);
if (titleMatch) {
const title = titleMatch[1].trim();
return title.replace(`{% highlightColor="green" %}`, '').trim();
} else {
return inputString.replace(`{% highlightColor="green" %}`, '').trim();
}
}
async function main() {
await generateEmbeddings();
}
// <astro-island> can also contain content so we need to make them plain HTML first
function preprocessAstroIslands(html: string): string {
const astroIslandPattern = /<astro-island[^>]*>([\s\S]*?)<\/astro-island>/g;
let processed = html;
let match;
while ((match = astroIslandPattern.exec(processed)) !== null) {
const fullIsland = match[0];
const islandContent = match[1];
const templateMatch = islandContent.match(/<template data-astro-template[^>]*>([\s\S]*?)<\/template>/);
if (templateMatch) {
const templateContent = templateMatch[1];
const visibleContent = islandContent.replace(/<template data-astro-template[^>]*>[\s\S]*?<\/template>/, '');
const cleanedVisible = visibleContent.replace(/<!--astro:end-->/, '');
const replacement = `<div class="astro-island-content">${cleanedVisible}${templateContent}</div>`;
processed = processed.replace(fullIsland, replacement);
}
}
return processed;
}
async function htmlToMarkdown(html:string): Promise<string> {
const mainMatch = html.match(/<main[^>]*>([\s\S]*?)<\/main>/);
const htmlToProcess = mainMatch ? mainMatch[1] : html;
const processedHtml = preprocessAstroIslands(htmlToProcess);
const file = await unified()
.use(rehypeParse, { fragment: true })
.use(remarkGfm)
.use(rehypeRemark)
.use(remarkStringify, {
bullet: '-',
emphasis: '*',
strong: '*',
fence: '`',
fences: true,
incrementListMarker: true
})
.process(processedHtml);
return String(file);
}
main().catch((err) => console.error(err));
@@ -0,0 +1,21 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "out-tsc",
"tsBuildInfoFile": "out-tsc/tsconfig.app.tsbuildinfo",
"rootDir": "src",
"types": ["node"],
"esModuleInterop": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"allowJs": true,
"composite": false
},
"exclude": [
"out-tsc",
"jest.config.ts",
"src/**/*.spec.ts",
"src/**/*.test.ts"
],
"include": ["src/**/*.ts", "src/**/*.mts"]
}
@@ -0,0 +1,17 @@
{
"extends": "../../../tsconfig.base.json",
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.spec.json"
}
],
"compilerOptions": {
"esModuleInterop": true,
"target": "esnext"
}
}
@@ -0,0 +1,16 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "out-tsc",
"types": ["jest", "node"],
"allowImportingTsExtensions": true,
"emitDeclarationOnly": true
},
"exclude": ["out-tsc"],
"include": [
"jest.config.ts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}
+48
View File
@@ -0,0 +1,48 @@
import {
RULE_NAME as ensurePnpmLockVersionName,
rule as ensurePnpmLockVersion,
} from './rules/ensure-pnpm-lock-version';
import {
RULE_NAME as validCommandObjectName,
rule as validCommandObject,
} from './rules/valid-command-object';
import {
RULE_NAME as requireWindowsHideName,
rule as requireWindowsHide,
} from './rules/require-windows-hide';
import {
RULE_NAME as validSchemaDescriptionName,
rule as validSchemaDescription,
} from './rules/valid-schema-description';
/**
* Import your custom workspace rules at the top of this file.
*
* For example:
*
* import { RULE_NAME as myCustomRuleName, rule as myCustomRule } from './rules/my-custom-rule';
*
* In order to quickly get started with writing rules you can use the
* following generator command and provide your desired rule name:
*
* ```sh
* npx nx g @nx/eslint:workspace-rule {{ NEW_RULE_NAME }}
* ```
*/
module.exports = {
/**
* Apply the imported custom rules here.
*
* For example (using the example import above):
*
* rules: {
* [myCustomRuleName]: myCustomRule
* }
*/
rules: {
[validSchemaDescriptionName]: validSchemaDescription,
[validCommandObjectName]: validCommandObject,
[ensurePnpmLockVersionName]: ensurePnpmLockVersion,
[requireWindowsHideName]: requireWindowsHide,
},
};
+24
View File
@@ -0,0 +1,24 @@
/* eslint-disable */
module.exports = {
displayName: 'eslint-rules',
globals: {},
transform: {
'^.+\\.[tj]s$': [
'ts-jest',
{
tsconfig: '<rootDir>/tsconfig.spec.json',
},
],
},
moduleFileExtensions: ['ts', 'js', 'html'],
preset: '../../jest.preset.js',
moduleNameMapper: {
// Override for tools packages - point to packages directory
'^@nx/devkit$': '<rootDir>/../../packages/devkit/index.ts',
'^@nx/devkit/testing$': '<rootDir>/../../packages/devkit/testing.ts',
'^@nx/devkit/internal-testing-utils$':
'<rootDir>/../../packages/devkit/internal-testing-utils.ts',
'^@nx/devkit/src/(.*)$': '<rootDir>/../../packages/devkit/src/$1',
},
};
+6
View File
@@ -0,0 +1,6 @@
{
"name": "eslint-rules",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "tools/eslint-rules",
"targets": {}
}
+23
View File
@@ -0,0 +1,23 @@
/**
* We have a custom lint rule for our pnpm-lock.yaml file and naturally ESLint does not natively know how to parse it.
* Rather than using a full yaml parser for this one case (which will need to spend time creating a real AST for the giant
* lock file), we can instead use a custom parser which just immediately returns a dummy AST and then build the reading of
* the lock file into the rule itself.
*/
module.exports = {
parseForESLint: (code) => ({
ast: {
type: 'Program',
loc: { start: 0, end: code.length },
range: [0, code.length],
body: [],
comments: [],
tokens: [],
},
services: { isPlain: true },
scopeManager: null,
visitorKeys: {
Program: [],
},
}),
};
@@ -0,0 +1,109 @@
/**
* This file sets you up with structure needed for an ESLint rule.
*
* It leverages utilities from @typescript-eslint to allow TypeScript to
* provide autocompletions etc for the configuration.
*
* Your rule's custom logic will live within the create() method below
* and you can learn more about writing ESLint rules on the official guide:
*
* https://eslint.org/docs/developer-guide/working-with-rules
*
* You can also view many examples of existing rules here:
*
* https://github.com/typescript-eslint/typescript-eslint/tree/master/packages/eslint-plugin/src/rules
*/
import { ESLintUtils } from '@typescript-eslint/utils';
import { closeSync, openSync, readSync } from 'node:fs';
// NOTE: The rule will be available in ESLint configs as "@nx/workspace-ensure-pnpm-lock-version"
export const RULE_NAME = 'ensure-pnpm-lock-version';
export const rule = ESLintUtils.RuleCreator(() => __filename)({
name: RULE_NAME,
meta: {
type: 'problem',
docs: {
description: ``,
},
schema: [
{
type: 'object',
properties: {
version: {
type: 'string',
},
},
additionalProperties: false,
},
],
messages: {
unparseableLockfileVersion:
'Could not parse lockfile version from pnpm-lock.yaml, the file may be corrupted or the ensure-pnpm-lock-version lint rule may need to be updated.',
incorrectLockfileVersion:
'pnpm-lock.yaml has a lockfileVersion of {{version}}, but {{expectedVersion}} is required.',
},
},
defaultOptions: [],
create(context) {
// Read upon creation of the rule, the contents should not change during linting
const lockfileFirstLine = readFirstLineSync('pnpm-lock.yaml');
// Extract the version number, it will be a string in single quotes
const lockfileVersion = lockfileFirstLine.match(
/lockfileVersion:\s*'([^']+)'/
)?.[1];
const options = context.options as { version: string }[];
if (!Array.isArray(options) || options.length === 0) {
throw new Error('Expected an array of options with a version property');
}
const expectedLockfileVersion = options[0].version;
return {
Program(node) {
if (!lockfileVersion) {
context.report({
node,
messageId: 'unparseableLockfileVersion',
});
return;
}
if (lockfileVersion !== expectedLockfileVersion) {
context.report({
node,
messageId: 'incorrectLockfileVersion',
data: {
version: lockfileVersion,
expectedVersion: expectedLockfileVersion,
},
});
}
},
};
},
});
/**
* pnpm-lock.yaml is a huge file, so only read the first line as efficiently as possible
* for optimum linting performance.
*/
function readFirstLineSync(filePath: string) {
const BUFFER_SIZE = 64; // Optimized for the expected line length
const buffer = Buffer.alloc(BUFFER_SIZE);
let line = '';
let bytesRead: number;
let fd: number;
try {
fd = openSync(filePath, 'r');
bytesRead = readSync(fd, buffer, 0, BUFFER_SIZE, 0);
line = buffer.toString('utf8', 0, bytesRead).split('\n')[0];
} catch (err) {
throw err; // Re-throw to allow caller to handle
} finally {
if (fd !== undefined) {
closeSync(fd);
}
}
return line;
}
@@ -0,0 +1,79 @@
import { RuleTester } from '@typescript-eslint/rule-tester';
import * as parser from '@typescript-eslint/parser';
import { rule, RULE_NAME } from './require-windows-hide';
const ruleTester = new RuleTester({
languageOptions: { parser },
});
ruleTester.run(RULE_NAME, rule, {
valid: [
// spawn with windowsHide: true
`import { spawn } from 'child_process';
spawn('echo', ['hello'], { stdio: 'inherit', windowsHide: true });`,
// execSync with windowsHide: true
`import { execSync } from 'child_process';
execSync('echo hello', { windowsHide: true });`,
// spawnSync with windowsHide: true
`import { spawnSync } from 'child_process';
spawnSync('echo', ['hello'], { windowsHide: true });`,
// Not a spawn function, should not report
`someOtherFunction({ stdio: 'inherit' });`,
// Variable as last arg (could be options) - skip
`import { spawn } from 'child_process';
const opts = { windowsHide: true };
spawn('echo', ['hello'], opts);`,
// Test file should be ignored
{
code: `import { spawn } from 'child_process';
spawn('echo', ['hello'], { stdio: 'inherit' });`,
filename: 'some-file.spec.ts',
},
],
invalid: [
// spawn missing windowsHide
{
code: `import { spawn } from 'child_process';
spawn('echo', ['hello'], { stdio: 'inherit' });`,
errors: [{ messageId: 'missingWindowsHide' }],
},
// spawn with windowsHide: false
{
code: `import { spawn } from 'child_process';
spawn('echo', ['hello'], { stdio: 'inherit', windowsHide: false });`,
errors: [{ messageId: 'windowsHideMustBeTrue' }],
},
// execSync missing windowsHide
{
code: `import { execSync } from 'child_process';
execSync('echo hello', { encoding: 'utf-8' });`,
errors: [{ messageId: 'missingWindowsHide' }],
},
// execSync with no options at all
{
code: `import { execSync } from 'child_process';
execSync('echo hello');`,
errors: [{ messageId: 'missingWindowsHide' }],
},
// spawn with variable command but no options
{
code: `import { spawn } from 'child_process';
const cmd = 'echo';
spawn(cmd, ['hello']);`,
errors: [{ messageId: 'missingWindowsHide' }],
},
// Member expression: child_process.spawn
{
code: `import * as cp from 'child_process';
cp.spawn('echo', ['hello'], { stdio: 'inherit' });`,
errors: [{ messageId: 'missingWindowsHide' }],
},
// Member expression as args (no options) - must still report
{
code: `import { spawn } from 'child_process';
const config = { args: ['hello'] };
spawn('echo', config.args);`,
errors: [{ messageId: 'missingWindowsHide' }],
},
],
});
@@ -0,0 +1,120 @@
import { ESLintUtils, TSESTree } from '@typescript-eslint/utils';
// NOTE: The rule will be available in ESLint configs as "@nx/workspace-require-windows-hide"
export const RULE_NAME = 'require-windows-hide';
const SPAWN_FUNCTIONS = new Set([
'spawn',
'spawnSync',
'exec',
'execSync',
'execFile',
'execFileSync',
]);
export const rule = ESLintUtils.RuleCreator(() => __filename)({
name: RULE_NAME,
meta: {
type: 'problem',
docs: {
description:
'Ensures that child_process spawn/exec calls include windowsHide: true to prevent console windows from flashing on Windows.',
},
schema: [],
messages: {
missingWindowsHide:
'windowsHide must be set to true to prevent console windows from flashing on Windows.',
windowsHideMustBeTrue:
'windowsHide must be set to true to prevent console windows from flashing on Windows.',
},
},
defaultOptions: [],
create(context) {
// Skip spec/test files
if (
context.physicalFilename.endsWith('.spec.ts') ||
context.physicalFilename.endsWith('.test.ts')
) {
return {};
}
return {
CallExpression(node: TSESTree.CallExpression) {
const calleeName = getCalleeName(node);
if (!calleeName || !SPAWN_FUNCTIONS.has(calleeName)) {
return;
}
// Find the options argument (object expression) in the call arguments
const optionsArg = node.arguments.find(
(arg): arg is TSESTree.ObjectExpression =>
arg.type === 'ObjectExpression'
);
if (!optionsArg) {
// Check if the last argument could be options passed via a non-
// literal expression. `Identifier` covers `spawn(..., opts)` and
// `SpreadElement` covers `spawn(..., ...rest)`. The rule cannot
// statically prove what these resolve to, so it gives up rather
// than false-positive on a deliberate caller pattern.
const lastArg = node.arguments[node.arguments.length - 1];
const hasVariableOptions =
lastArg &&
(lastArg.type === 'Identifier' || lastArg.type === 'SpreadElement');
if (hasVariableOptions) {
return;
}
// No options object provided at all - report error
context.report({
messageId: 'missingWindowsHide',
node: node,
});
return;
}
// Look for windowsHide property
const windowsHideProp = optionsArg.properties.find(
(prop): prop is TSESTree.Property =>
prop.type === 'Property' &&
prop.key.type === 'Identifier' &&
prop.key.name === 'windowsHide'
);
if (!windowsHideProp) {
// windowsHide is missing from the options object
context.report({
messageId: 'missingWindowsHide',
node: optionsArg,
});
return;
}
// windowsHide exists but check it's set to true
if (
windowsHideProp.value.type === 'Literal' &&
windowsHideProp.value.value !== true
) {
context.report({
messageId: 'windowsHideMustBeTrue',
node: windowsHideProp,
});
}
},
};
},
});
function getCalleeName(node: TSESTree.CallExpression): string | null {
// Direct call: spawn(...)
if (node.callee.type === 'Identifier') {
return node.callee.name;
}
// Member expression: child_process.spawn(...)
if (
node.callee.type === 'MemberExpression' &&
node.callee.property.type === 'Identifier'
) {
return node.callee.property.name;
}
return null;
}
@@ -0,0 +1,150 @@
import { RuleTester } from '@typescript-eslint/rule-tester';
import * as parser from '@typescript-eslint/parser';
import { rule, RULE_NAME } from './valid-command-object';
const ruleTester = new RuleTester({
languageOptions: { parser },
});
ruleTester.run(RULE_NAME, rule, {
valid: [
// Not a command object file, so should not report
`module.exports = {
description: 'My command',
}`,
// Not a command object file, so should not report
`module.exports = {
describe: 'My command',
}`,
{
// Already has a . at the end of the description
code: `module.exports = {
description: 'My command.',
}`,
filename: 'some/path/command-object.ts',
},
{
// Already has a . at the end of the description
code: `module.exports = {
describe: 'My command.',
}`,
filename: 'another/command-object.ts',
},
{
// Boolean property, so should not report
code: `module.exports = {
describe: false,
};`,
filename: 'command-object.ts',
},
],
invalid: [
// With "describe" and single quotes
{
filename: 'some/path/command-object.ts',
code: `
const planCheckCommand: CommandModule<NxReleaseArgs, PlanCheckOptions> = {
command: 'plan:check',
describe:
'Ensure that all touched projects have an applicable version plan created for them',
builder: (yargs) => withAffectedOptions(yargs),
handler: async (args) => {
const release = await import('./plan-check');
const result = await release.releasePlanCheckCLIHandler(args);
process.exit(result);
},
};
`,
errors: [
{
messageId: 'validCommandDescription',
line: 4,
column: 11,
},
],
output: `
const planCheckCommand: CommandModule<NxReleaseArgs, PlanCheckOptions> = {
command: 'plan:check',
describe:
'Ensure that all touched projects have an applicable version plan created for them.',
builder: (yargs) => withAffectedOptions(yargs),
handler: async (args) => {
const release = await import('./plan-check');
const result = await release.releasePlanCheckCLIHandler(args);
process.exit(result);
},
};
`,
},
// With "description" and backticks
{
filename: 'some/path/command-object.ts',
code: `
const planCheckCommand: CommandModule<NxReleaseArgs, PlanCheckOptions> = {
command: 'plan:check',
description: \`Ensure that all touched projects have an applicable version plan created for them\`,
builder: (yargs) => withAffectedOptions(yargs),
handler: async (args) => {
const release = await import('./plan-check');
const result = await release.releasePlanCheckCLIHandler(args);
process.exit(result);
},
};
`,
errors: [
{
messageId: 'validCommandDescription',
line: 4,
column: 11,
},
],
output: `
const planCheckCommand: CommandModule<NxReleaseArgs, PlanCheckOptions> = {
command: 'plan:check',
description: \`Ensure that all touched projects have an applicable version plan created for them.\`,
builder: (yargs) => withAffectedOptions(yargs),
handler: async (args) => {
const release = await import('./plan-check');
const result = await release.releasePlanCheckCLIHandler(args);
process.exit(result);
},
};
`,
},
// With "description" and double quotes
{
filename: 'some/path/command-object.ts',
code: `
const planCheckCommand: CommandModule<NxReleaseArgs, PlanCheckOptions> = {
command: 'plan:check',
description: "Ensure that all touched projects have an applicable version plan created for them",
builder: (yargs) => withAffectedOptions(yargs),
handler: async (args) => {
const release = await import('./plan-check');
const result = await release.releasePlanCheckCLIHandler(args);
process.exit(result);
},
};
`,
errors: [
{
messageId: 'validCommandDescription',
line: 4,
column: 11,
},
],
output: `
const planCheckCommand: CommandModule<NxReleaseArgs, PlanCheckOptions> = {
command: 'plan:check',
description: "Ensure that all touched projects have an applicable version plan created for them.",
builder: (yargs) => withAffectedOptions(yargs),
handler: async (args) => {
const release = await import('./plan-check');
const result = await release.releasePlanCheckCLIHandler(args);
process.exit(result);
},
};
`,
},
],
});
@@ -0,0 +1,87 @@
/**
* This file sets you up with structure needed for an ESLint rule.
*
* It leverages utilities from @typescript-eslint to allow TypeScript to
* provide autocompletions etc for the configuration.
*
* Your rule's custom logic will live within the create() method below
* and you can learn more about writing ESLint rules on the official guide:
*
* https://eslint.org/docs/developer-guide/working-with-rules
*
* You can also view many examples of existing rules here:
*
* https://github.com/typescript-eslint/typescript-eslint/tree/master/packages/eslint-plugin/src/rules
*/
import { ASTUtils, ESLintUtils, TSESTree } from '@typescript-eslint/utils';
// NOTE: The rule will be available in ESLint configs as "@nx/workspace-valid-command-object"
export const RULE_NAME = 'valid-command-object';
export const rule = ESLintUtils.RuleCreator(() => __filename)({
name: RULE_NAME,
meta: {
type: 'problem',
fixable: 'code',
docs: {
description: `Ensures that commands contain valid descriptions in order to provide consistent --help output and generated documentation`,
},
schema: [],
messages: {
validCommandDescription:
'A command description should end with a . character for consistency',
},
},
defaultOptions: [],
create(context) {
if (
!context.physicalFilename.endsWith('command-object.ts') &&
!(
context.physicalFilename.includes('nx/src/command-line/') &&
// Ignore the examples file, those descriptions work differently
!context.physicalFilename.endsWith('examples.ts') &&
// Ignore spec files
!context.physicalFilename.endsWith('.spec.ts')
) &&
!context.physicalFilename.includes('packages/create-nx-workspace')
) {
return {};
}
return {
'Property > :matches(Identifier[name="describe"], Identifier[name="description"], TaggedTemplateExpression)':
(node: TSESTree.Identifier) => {
const propertyNode = node.parent as TSESTree.Property;
const stringToCheck =
(propertyNode.value.type === 'Literal' &&
typeof propertyNode.value.value !== 'boolean') ||
propertyNode.value.type === 'TemplateLiteral'
? ASTUtils.getStringIfConstant(propertyNode.value)
: null;
// String description already ends with a . character (or some other form of punctuation)
if (
!stringToCheck ||
// Call trim() to avoid issues with trailing whitespace
stringToCheck.trim().endsWith('.') ||
stringToCheck.trim().endsWith('!') ||
stringToCheck.trim().endsWith('?')
) {
return;
}
context.report({
messageId: 'validCommandDescription',
node: propertyNode,
fix: (fixer) => {
// We need to take the closing ' or " or ` into account when applying the . character
return fixer.insertTextAfterRange(
[propertyNode.value.range[0], propertyNode.value.range[1] - 1],
'.'
);
},
});
},
};
},
});
@@ -0,0 +1,12 @@
import { RuleTester } from '@typescript-eslint/rule-tester';
import * as parser from '@typescript-eslint/parser';
import { rule, RULE_NAME } from './valid-schema-description';
const ruleTester = new RuleTester({
languageOptions: { parser },
});
ruleTester.run(RULE_NAME, rule, {
valid: [`const example = true;`],
invalid: [],
});
@@ -0,0 +1,80 @@
import { ESLintUtils } from '@typescript-eslint/utils';
import type { AST } from 'jsonc-eslint-parser' with { 'resolution-mode': 'import' };
// NOTE: The rule will be available in ESLint configs as "@nx/workspace/valid-schema-description"
export const RULE_NAME = 'valid-schema-description';
export const rule = ESLintUtils.RuleCreator(() => __filename)({
name: RULE_NAME,
meta: {
type: 'problem',
docs: {
description: `Ensures that nx schemas contain valid descriptions in order to provide consistent --help output for commands`,
},
fixable: 'code',
schema: [],
messages: {
requireSchemaDescriptionString:
'A schema description string is required in order to render --help output correctly',
validSchemaDescription:
'A schema description should end with a . character for consistency',
},
},
defaultOptions: [],
create(context) {
// jsonc-eslint-parser adds this property to parserServices where appropriate
if (!(context.sourceCode.parserServices as any).isJSON) {
return {};
}
return {
['JSONExpressionStatement > JSONObjectExpression'](
node: AST.JSONObjectExpression
) {
const descriptionParentJSONPropertyNode =
resolveDescriptionParentPropertyNode(node);
if (!descriptionParentJSONPropertyNode) {
context.report({
node: node as any,
messageId: 'requireSchemaDescriptionString',
});
return;
}
if (!descriptionParentJSONPropertyNode.value.value.endsWith('.')) {
context.report({
node: descriptionParentJSONPropertyNode.value as any,
messageId: 'validSchemaDescription',
fix: (fixer) => {
const [start, end] =
descriptionParentJSONPropertyNode.value.range;
return fixer.insertTextAfterRange(
[start, end - 1], // -1 to account for the closing " of the string
'.'
);
},
});
}
},
};
},
});
interface JSONPropertyWithStringLiteralValue extends AST.JSONProperty {
key: AST.JSONStringLiteral;
value: AST.JSONStringLiteral;
}
function resolveDescriptionParentPropertyNode(
node: AST.JSONObjectExpression
): JSONPropertyWithStringLiteralValue | null {
const descriptionParentJSONPropertyNode = node.properties.find((prop) => {
return (
prop.key.type === 'JSONLiteral' &&
prop.key.value === 'description' &&
prop.value.type === 'JSONLiteral' &&
typeof prop.value.value === 'string' &&
prop.value.value.length > 0
);
});
return descriptionParentJSONPropertyNode as JSONPropertyWithStringLiteralValue;
}
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.base.json",
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lint.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "out-tsc",
"types": ["node"]
},
"exclude": ["**/*.spec.ts", "jest.config.ts", "out-tsc"],
"include": ["**/*.ts"]
}
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "out-tsc",
"rootDir": ".",
"types": ["jest", "node"],
"isolatedModules": true
},
"exclude": ["out-tsc"],
"include": ["**/*.test.ts", "**/*.spec.ts", "**/*.d.ts", "jest.config.ts"],
"references": [{ "path": "./tsconfig.lint.json" }]
}
+287
View File
@@ -0,0 +1,287 @@
# Nx AI Landing Page: Content Strategy & Structure
## Executive Summary
Based on the analysis of Nx's AI blog series and existing landing pages, here's a comprehensive strategy for an AI-focused landing page that positions Nx as the essential foundation for AI-powered development in monorepos.
## Page Structure & Content Strategy
### Hero Section
**Primary Headline:** "Make Your AI Assistant 10x Smarter"
**Sub-headline:** "Integrate Nx's workspace intelligence directly into your existing AI assistant through MCP - transforming basic code helpers into architecturally-aware development partners."
**Primary CTA:** "Enhance Your AI Assistant"
**Secondary CTA:** "Watch 3-min Demo"
### Problem Statement Section
**Headline:** "Why Your AI Assistant Struggles with Enterprise Codebases"
**Four Core Problems:**
1. **Limited Context** - LLMs only see individual files, missing architectural relationships. As monorepos grow larger, this problem compounds dramatically, requiring developers to manually provide context for every interaction.
2. **Inconsistent Output** - AI generates code that doesn't follow your team's best practices and may introduce breaking changes by deprecating components it doesn't see being used elsewhere in the codebase.
3. **No Workspace Awareness** - Can't understand project dependencies, ownership, or integration points, making it difficult for AI to know where to start when fixing issues across multiple projects.
4. **Manual Context Burden** - Developers must constantly provide the same contextual information about project structure, relationships, and interdependencies, negating much of the productivity gains AI promises.
**Visual:** Diagram showing LLM with limited "street view" vs. Nx providing "map view" of codebase, with callouts showing the increasing context burden as repository size grows.
**Additional Callout Box:**
"As monorepos scale, AI tools become progressively less effective - a challenge that only architectural intelligence can solve. While type safety provides some guardrails, it's not enough without true workspace understanding."
### Solution Overview Section
**Headline:** "Nx Provides the Missing Context Your AI Needs"
**Core Value Props:**
1. **Architectural Awareness** - Move from file-level to workspace-level understanding
2. **Predictable + Intelligent** - Combine consistent generators with AI customization
3. **Integrated Workflows** - Connect editor, CI, and AI for seamless development
### Features Deep Dive
#### 1. Workspace Intelligence
**Headline:** "Elevate Your AI from File-Level to Architecture-Level Understanding"
**Content:**
- Project relationship mapping
- Dependency analysis and impact assessment
- Team ownership and responsibility identification
- Technology stack and configuration understanding
**Demo:** "Ask your AI: 'If I change the API of this library, which teams need to know?'"
**Resources:**
- 📹 [Watch: Nx Just Made Your LLM Way Smarter](https://youtu.be/RNilYmJJzdk)
- 📖 [Blog: Nx Just Made Your LLM Way Smarter](/blog/nx-just-made-your-llm-smarter)
#### 2. CI Integration & Failure Resolution
**Headline:** "Fix CI Issues Before You Even Know They Exist"
**Content:**
- Real-time CI failure notifications in your editor
- AI-powered failure analysis and suggested fixes
- Access to detailed Nx Cloud pipeline data
- Automated resolution suggestions
**Demo:** "Get notified of CI failures and let AI suggest the fix"
**Resources:**
- 📹 [Watch: Connect Your Editor, CI and LLMs](https://youtu.be/fPqPh4h8RJg)
- 📖 [Blog: Save Time: Connecting Your Editor, CI and LLMs](/blog/nx-editor-ci-llm-integration)
#### 3. Terminal Integration & Live Assistance
**Headline:** "Your AI Assistant Sees What You See in the Terminal"
**Content:**
- Real-time terminal output awareness
- Live task execution monitoring
- Contextual error analysis and fixes
- No more copy-pasting terminal errors
**Demo:** "Run a task that fails, and AI immediately offers solutions based on the actual error output"
**Resources:**
- 📖 Blog post coming soon
#### 4. Smart Code Generation
**Headline:** "Predictable Generators + AI Intelligence"
**Content:**
- Nx generators provide consistent, tested scaffolding
- AI adds contextual customization and integration
- Human-in-the-loop workflow for quality control
- Workspace-aware code integration
**Demo:** "Generate a new library and automatically connect it to existing projects"
**Resources:**
- 📹 [Watch: Enhancing Nx Generators with AI](https://youtu.be/PXNjedYhZDs)
- 📖 [Blog: Combining Predictability and Intelligence With Nx Generators and AI](/blog/nx-generators-ai-integration)
#### 5. Documentation-Aware Assistance
**Headline:** "Always Up-to-Date, Never Hallucinating"
**Content:**
- Live access to current Nx documentation
- Context-aware configuration guidance
- Best practices enforcement
- Migration assistance
**Resources:**
- 📹 [Watch: Making Cursor Smarter with MCP](https://youtu.be/V2W94Sq_v6A)
- 📖 [Blog: Making Cursor Smarter with an MCP Server For Nx](/blog/nx-made-cursor-smarter)
### Technical Implementation Section
**Headline:** "Powered by Nx's Rich Workspace Intelligence"
**Content:**
Nx already maintains comprehensive metadata about your workspace to optimize builds, manage dependencies, and enforce architectural boundaries. The Nx daemon continuously monitors your workspace, tracking project relationships and updates in real-time to keep this intelligence current and accurate.
**How It Works:**
- Nx daemon runs in the background, maintaining up-to-date workspace metadata
- This rich contextual data is processed and optimized for AI consumption
- Intelligence is exposed through the Model Context Protocol (MCP)
- Integrates seamlessly into your existing AI assistant workflows
**The key advantage:** Rather than building something entirely new, this enhances the AI tools you already use and trust, making your existing collaboration with LLMs significantly more powerful and context-aware.
**Integration Options:**
- **Nx Console Extension**: Available for VSCode, Cursor, and IntelliJ
- **Pure MCP Server**: Works with any MCP-compatible client (Claude Desktop, Cline, Windsurf, etc.)
- **Existing Workflow**: Enhances your current AI assistant without changing your development habits
### Use Cases & Examples
#### Enterprise Developer
**Scenario:** "Understanding impact of API changes across 50+ projects in a large workspace"
**Solution:** AI uses project graph to identify all affected teams and suggests migration strategy
#### New Team Member
**Scenario:** "Getting up to speed on complex multi-project architecture"
**Solution:** AI explains project relationships, ownership, and where to implement features
#### DevOps Engineer
**Scenario:** "Optimizing CI/CD pipeline performance across multiple related projects"
**Solution:** AI analyzes Nx Cloud data to suggest task distribution and caching improvements
### Competitive Differentiation
**Headline:** "Why Large Workspaces Are AI Future-Proof"
**Key Points:**
1. **Complete Context** - All related projects in one workspace vs. scattered repositories
2. **Rich Metadata** - Nx's architectural understanding vs. basic file access
3. **Predictable Patterns** - Consistent generators vs. variable AI output
4. **Integrated Tooling** - Connected workflow vs. isolated tools
### Social Proof Section
**Headline:** "Join Forward-Thinking Teams Already Using AI-Enhanced Nx"
**Featured Testimonials:**
- Focus on teams using AI + Nx successfully
- Metrics: reduced onboarding time, faster feature delivery
- Use existing customer logos where applicable
### Getting Started Section
**Headline:** "Transform Your AI Assistant in Minutes"
**Three Steps:**
1. **Install Nx Console** - Available for VSCode, Cursor, IntelliJ
2. **Enable MCP Integration** - One-click setup
3. **Start Asking Better Questions** - AI now understands your workspace
**Technical Requirements:**
- Existing Nx workspace or `nx init` for new setup
- Compatible AI assistant (Copilot, Claude, etc.)
- Nx Console extension
### Resources & Next Steps
**Featured Content:**
- 📹 [Nx Just Made Your LLM Way Smarter](https://youtu.be/RNilYmJJzdk) - Foundation overview
- 📹 [Why Nx and AI Work So Well Together](https://youtu.be/[video-url]) - Strategic perspective
- 📹 [Making Cursor Smarter with MCP](https://youtu.be/V2W94Sq_v6A) - Cursor setup guide
- 📹 [Nx MCP for VS Code Copilot](https://youtu.be/dRQq_B1HSLA) - VSCode setup guide
- 📹 [Enhancing Nx Generators with AI](https://youtu.be/PXNjedYhZDs) - Smart generation workflow
**Blog Series:**
- 📖 [Nx Just Made Your LLM Way Smarter](/blog/nx-just-made-your-llm-smarter) (foundational post)
- 📖 [Making Cursor Smarter with an MCP Server](/blog/nx-made-cursor-smarter) (Cursor integration)
- 📖 [Nx MCP Now Available for VS Code Copilot](/blog/nx-mcp-vscode-copilot) (VSCode integration)
- 📖 [Nx and AI: Why They Work so Well Together](/blog/nx-and-ai-why-they-work-together) (strategic overview)
- 📖 [Combining Predictability and Intelligence With Nx Generators and AI](/blog/nx-generators-ai-integration) (generator workflow)
**Additional Resources:**
- Live demo videos
- Documentation links
- Community Discord for questions
- Blog series for deep dives
## Page Optimization Strategy
### SEO Keywords
**Primary:** "AI workspace development", "LLM code assistant", "Nx AI integration", "multi-project AI tools"
**Secondary:** "enterprise AI development", "intelligent code generation", "MCP server", "workspace AI tools"
### Conversion Optimization
1. **Multiple entry points** - Different CTAs for different user types
2. **Progressive disclosure** - Start with benefits, dive into technical details
3. **Social proof throughout** - Testimonials and usage stats
4. **Risk reduction** - Free trial, easy setup, existing workspace compatibility
### Developer-Focused Messaging
- Technical accuracy and specificity
- Real code examples and demos
- Focus on productivity gains and workflow improvements
- Emphasis on maintaining control and predictability
## Content Tone & Voice
**Technical but Accessible:** Explain complex concepts clearly without dumbing down
**Benefit-Focused:** Lead with outcomes, support with features
**Confident but Not Overhyped:** Realistic about current capabilities while showing vision
**Developer-to-Developer:** Written by and for engineers who understand the pain points
## Success Metrics
### Primary KPIs
- Nx Console downloads/installs
- MCP server configurations
- AI-related feature adoption
- Time-to-first-AI-query in workspace
### Secondary KPIs
- Page engagement time
- Video completion rates
- Documentation page visits from AI landing page
- Community Discord joins related to AI features
## Implementation Recommendations
1. **Start with Core Message Testing** - A/B test hero section messaging
2. **Progressive Rollout** - Begin with essential features, add advanced use cases
3. **Continuous Content Updates** - Regular examples and case studies as features evolve
4. **Community Feedback Loop** - Use Discord and GitHub discussions to refine messaging
This landing page strategy positions Nx as the essential infrastructure for AI-powered development, focusing on the unique value of architectural awareness and workspace intelligence that generic AI tools simply cannot provide.
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env node
import { readFileSync } from 'fs';
import { execSync } from 'child_process';
try {
const config = JSON.parse(
readFileSync(new URL('../../.rawdocs.local.json', import.meta.url))
);
execSync(
`node ${config.rawDocsPath}/scripts/analyze-changes.mjs ${process.argv
.slice(2)
.join(' ')}`,
{ stdio: 'inherit' }
);
} catch {
console.log(
`Error: Run installation with:\ngh api repos/nrwl/raw-docs/contents/install.sh --jq '.content' | base64 -d | bash`
);
}
+64
View File
@@ -0,0 +1,64 @@
# Repository Update System
Automated system for updating multiple repositories with Nx migrations.
## Usage
**Update Individual Repository** (setup handled automatically):
```bash
nx run update-repos:update-nx-repo
nx run update-repos:update-ocean-repo
nx run update-repos:update-nx-examples-repo
nx run update-repos:update-nx-console-repo
```
**Update All Repositories**:
```bash
nx run update-repos:update-all-repos
```
Or using the convenient npm script:
```bash
pnpm update-all-repos
```
> **Note**: No separate setup step is required. Each update task automatically handles repository setup if needed.
## How It Works
**Automatic Setup** (if repository doesn't exist):
1. Uses GitHub CLI to clone repositories to OS temp directory
2. Configures repositories from `config/repos.json`
3. Skips setup if repository already exists (noop)
**Update Process**:
1. Create update branch (`upnx`) from remote main branch
2. Detect package manager (pnpm/yarn/bun/npm) from lockfiles
3. Run `nx migrate next` to update to latest Nx version
4. Install updated dependencies
5. Run `nx migrate --run-migrations --create-commits` (auto-generates commits)
6. Clean up `migrations.json` file after successful migration
7. Run `post-nx-update` script if it exists in package.json (optional)
8. Commit any changes from post-nx-update script separately
9. Run `nx reset` to clear cache (prevents prepush hook issues)
10. Push branch to remote (with `--no-verify` to skip git hooks)
11. Create new PR or update existing PR with current version info
12. Open PR in browser
**Post-Update Hook**:
- Repositories can define a `post-nx-update` script in their root package.json
- This script runs after Nx migrations complete but before pushing changes
- Any changes made by the script are committed separately
- If the script doesn't exist, the process continues normally (no failure)
**PR Management**:
- Creates new PRs when none exist for the update branch
- Updates existing PRs with latest version information
- Always opens PRs in browser regardless of create vs update
+29
View File
@@ -0,0 +1,29 @@
{
"repositories": {
"nx": {
"repo": "nrwl/nx",
"branch": "master",
"packageManager": "pnpm"
},
"ocean": {
"repo": "nrwl/ocean",
"branch": "main",
"packageManager": "pnpm"
},
"nx-examples": {
"repo": "nrwl/nx-examples",
"branch": "master",
"packageManager": "yarn"
},
"nx-console": {
"repo": "nrwl/nx-console",
"branch": "master",
"packageManager": "yarn"
},
"nx-labs": {
"repo": "nrwl/nx-labs",
"branch": "main",
"packageManager": "bun"
}
}
}
+65
View File
@@ -0,0 +1,65 @@
{
"name": "update-repos",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "tools/update-repos/src",
"projectType": "library",
"targets": {
"setup-update-repos": {
"executor": "nx:run-commands",
"dependsOn": ["build-base"],
"options": {
"command": "node tools/update-repos/dist/src/setup-repos.js"
}
},
"update-nx-repo": {
"executor": "nx:run-commands",
"dependsOn": ["build-base"],
"options": {
"command": "node tools/update-repos/dist/src/update-repo.js nx"
}
},
"update-ocean-repo": {
"executor": "nx:run-commands",
"dependsOn": ["build-base"],
"options": {
"command": "node tools/update-repos/dist/src/update-repo.js ocean"
}
},
"update-nx-examples-repo": {
"executor": "nx:run-commands",
"dependsOn": ["build-base"],
"options": {
"command": "node tools/update-repos/dist/src/update-repo.js nx-examples"
}
},
"update-nx-console-repo": {
"executor": "nx:run-commands",
"dependsOn": ["build-base"],
"options": {
"command": "node tools/update-repos/dist/src/update-repo.js nx-console"
}
},
"update-nx-labs-repo": {
"executor": "nx:run-commands",
"dependsOn": ["build-base"],
"options": {
"command": "node tools/update-repos/dist/src/update-repo.js nx-labs"
}
},
"update-repos": {
"executor": "nx:run-commands",
"dependsOn": ["update-ocean-repo", "update-nx-console-repo"],
"options": {
"command": "echo '🎉 Ocean and NxConsole repositories updated successfully!'"
}
},
"update-all-repos": {
"executor": "nx:run-commands",
"dependsOn": ["update-*-repo"],
"options": {
"command": "echo '🎉 All repositories updated successfully!'"
}
}
},
"tags": ["npm:private"]
}
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env ts-node
import { execSync, exec } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { promisify } from 'util';
const execAsync = promisify(exec);
interface RepositoryConfig {
repo: string;
branch: string;
packageManager: string;
}
interface Config {
repositories: Record<string, RepositoryConfig>;
}
const SCRIPT_DIR = __dirname;
const REPOS_DIR = path.join(os.tmpdir(), 'updating-nx', 'repos');
// Compiled to tools/update-repos/dist/src, so the package root is two levels up.
const CONFIG_FILE = path.join(SCRIPT_DIR, '..', '..', 'config', 'repos.json');
function log(message: string) {
const timestamp = new Date().toISOString().replace('T', ' ').substring(0, 19);
console.log(`${timestamp} [SETUP] ${message}`);
}
function getErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return String(error);
}
async function setupClone(
repoName: string,
repoIdentifier: string,
repoBranch: string
): Promise<void> {
const cloneDir = path.join(REPOS_DIR, repoName);
log(`Setting up clone for ${repoName}...`);
// Remove existing clone if it exists
if (fs.existsSync(cloneDir)) {
log(`Removing existing clone for ${repoName}...`);
execSync(`rm -rf "${cloneDir}"`);
}
try {
// Clone repository using gh cli with specific branch
log(
`Cloning ${repoName} from ${repoIdentifier} (branch: ${repoBranch})...`
);
await execAsync(
`gh repo clone "${repoIdentifier}" "${cloneDir}" -- --depth 1 --branch "${repoBranch}"`
);
log(`Clone for ${repoName} created at ${cloneDir}`);
} catch (error) {
throw new Error(
`Failed to setup clone for ${repoName}: ${getErrorMessage(error)}`
);
}
}
async function checkRequiredTools(): Promise<void> {
try {
execSync('gh --version', { stdio: 'ignore' });
} catch {
throw new Error(
'GitHub CLI (gh) is required but not installed. Please install gh to continue.'
);
}
}
async function main(): Promise<void> {
log('Starting repository cloning...');
try {
// Check if gh cli is available
await checkRequiredTools();
// Check if config file exists
if (!fs.existsSync(CONFIG_FILE)) {
throw new Error(`Configuration file not found at ${CONFIG_FILE}`);
}
// Create repos directory if it doesn't exist
if (!fs.existsSync(REPOS_DIR)) {
log(`Creating repos directory: ${REPOS_DIR}`);
fs.mkdirSync(REPOS_DIR, { recursive: true });
}
// Read configuration
const config: Config = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
// Process repositories concurrently
const clonePromises = Object.entries(config.repositories).map(
([repoName, repoConfig]) =>
setupClone(repoName, repoConfig.repo, repoConfig.branch)
);
await Promise.all(clonePromises);
log('Repository cloning completed successfully!');
log(`Repositories are available in: ${REPOS_DIR}`);
} catch (error) {
log(`ERROR: ${getErrorMessage(error)}`);
process.exit(1);
}
}
if (require.main === module) {
main();
}
+603
View File
@@ -0,0 +1,603 @@
#!/usr/bin/env ts-node
import { execSync, exec, spawn } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { promisify } from 'util';
const execAsync = promisify(exec);
interface RepositoryConfig {
repo: string;
branch: string;
packageManager: string;
}
interface Config {
repositories: Record<string, RepositoryConfig>;
}
interface PullRequestInfo {
repoName: string;
title: string;
description: string;
url: string;
}
type PackageManager = 'npm' | 'yarn' | 'pnpm' | 'bun';
const SCRIPT_DIR = __dirname;
const REPOS_DIR = path.join(os.tmpdir(), 'updating-nx', 'repos');
// Compiled to tools/update-repos/dist/src, so the package root is two levels up.
const CONFIG_FILE = path.join(SCRIPT_DIR, '..', '..', 'config', 'repos.json');
function log(message: string) {
const timestamp = new Date().toISOString().replace('T', ' ').substring(0, 19);
console.log(`${timestamp} [UPDATE] ${message}`);
}
function getErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return String(error);
}
async function execWithOutput(
command: string,
cwd: string,
description?: string,
env?: NodeJS.ProcessEnv
): Promise<void> {
if (description) {
log(`🔄 ${description}`);
}
const finalCommand = command.startsWith('mise ')
? command
: `mise exec -- ${command}`;
log(`📝 Running: ${finalCommand}`);
return new Promise((resolve, reject) => {
const child = spawn('bash', ['-c', finalCommand], {
cwd,
stdio: ['inherit', 'pipe', 'pipe'],
env: env ? { ...process.env, ...env } : undefined,
});
child.stdout.on('data', (data) => {
const output = data.toString().trim();
if (output) {
console.log(` ${output}`);
}
});
child.stderr.on('data', (data) => {
const output = data.toString().trim();
if (output) {
console.log(` ⚠️ ${output}`);
}
});
child.on('close', (code) => {
if (code === 0) {
log(`✅ Completed: ${description || command}`);
resolve();
} else {
log(`❌ Failed: ${description || command} (exit code ${code})`);
reject(new Error(`Command failed with exit code ${code}: ${command}`));
}
});
child.on('error', (error) => {
log(`❌ Error: ${error.message}`);
reject(error);
});
});
}
function detectPackageManager(repoDir: string): PackageManager {
if (fs.existsSync(path.join(repoDir, 'pnpm-lock.yaml'))) {
return 'pnpm';
} else if (fs.existsSync(path.join(repoDir, 'yarn.lock'))) {
return 'yarn';
} else if (fs.existsSync(path.join(repoDir, 'bun.lockb'))) {
return 'bun';
} else if (fs.existsSync(path.join(repoDir, 'package-lock.json'))) {
return 'npm';
} else {
return 'npm'; // default fallback
}
}
function getInstallCommand(packageManager: PackageManager): string {
switch (packageManager) {
case 'pnpm':
return 'pnpm install';
case 'yarn':
return 'yarn install';
case 'bun':
return 'bun install';
case 'npm':
default:
return 'npm install';
}
}
function getMigrateCommand(packageManager: PackageManager): string {
switch (packageManager) {
case 'pnpm':
return 'pnpm exec nx migrate next';
case 'yarn':
return 'yarn nx migrate next';
case 'bun':
return 'bun nx migrate next';
case 'npm':
default:
return 'npx nx migrate next';
}
}
function getRunMigrationsCommand(packageManager: PackageManager): string {
switch (packageManager) {
case 'pnpm':
return 'pnpm exec nx migrate --run-migrations --create-commits --agentic';
case 'yarn':
return 'yarn nx migrate --run-migrations --create-commits --agentic';
case 'bun':
return 'bun nx migrate --run-migrations --create-commits --agentic';
case 'npm':
default:
return 'npx nx migrate --run-migrations --create-commits --agentic';
}
}
function getResetCommand(packageManager: PackageManager): string {
switch (packageManager) {
case 'pnpm':
return 'pnpm exec nx reset';
case 'yarn':
return 'yarn nx reset';
case 'bun':
return 'bun nx reset';
case 'npm':
default:
return 'npx nx reset';
}
}
function getPostUpdateCommand(packageManager: PackageManager): string {
switch (packageManager) {
case 'pnpm':
return 'pnpm run post-nx-update';
case 'yarn':
return 'yarn run post-nx-update';
case 'bun':
return 'bun run post-nx-update';
case 'npm':
default:
return 'npm run post-nx-update';
}
}
async function runPostUpdateScript(
repoDir: string,
packageManager: PackageManager
): Promise<boolean> {
try {
// Check if package.json exists and has post-nx-update script
const packageJsonPath = path.join(repoDir, 'package.json');
if (!fs.existsSync(packageJsonPath)) {
return false;
}
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
if (!packageJson.scripts || !packageJson.scripts['post-nx-update']) {
log('📋 No post-nx-update script found, skipping');
return false;
}
log('🔧 Found post-nx-update script, executing...');
const postUpdateCmd = getPostUpdateCommand(packageManager);
await execWithOutput(
postUpdateCmd,
repoDir,
'Running post-nx-update script'
);
// Check if the script made any changes
const { stdout } = await execAsync('git status --porcelain', {
cwd: repoDir,
});
if (stdout.trim()) {
log('📝 Post-nx-update script made changes, committing...');
await execWithOutput('git add .', repoDir, 'Staging post-update changes');
await execWithOutput(
'git commit -m "chore(repo): apply post-nx-update script changes"',
repoDir,
'Committing post-update changes'
);
return true;
} else {
log('📋 No changes from post-nx-update script');
return false;
}
} catch (error) {
log(
`⚠️ Warning: Failed to run post-nx-update script: ${getErrorMessage(
error
)}`
);
return false;
}
}
async function getNxVersion(
repoDir: string,
_packageManager: PackageManager
): Promise<string> {
try {
// Use Node.js to require nx package.json directly from the repo
const command = `node -e "console.log(require('nx/package.json').version)"`;
const { stdout } = await execAsync(command, { cwd: repoDir });
return stdout.trim();
} catch (error) {
// Fallback: try to read from package.json
try {
const packageJsonPath = path.join(repoDir, 'package.json');
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
const version =
packageJson.dependencies?.nx ||
packageJson.devDependencies?.nx ||
'unknown';
// Clean up version string (remove ^ ~ etc.)
if (version !== 'unknown') {
const versionMatch = version.match(/(\d+\.\d+\.\d+(?:-[^\s]+)?)/);
return versionMatch ? versionMatch[1] : version;
}
return version;
} catch {
return 'unknown';
}
}
}
function buildPullRequestInfo(
repoName: string,
updateBranch: string,
fromVersion: string,
toVersion: string
): PullRequestInfo {
const config: Config = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
const repoConfig = config.repositories[repoName];
const baseBranch = repoConfig.branch;
const title = `chore(repo): update nx to ${toVersion}`;
const description = `Updating Nx from ${fromVersion} to ${toVersion}`;
// Build a GitHub "create pull request" URL with the title and body pre-filled.
const params = new URLSearchParams({
expand: '1',
title,
body: description,
});
const url = `https://github.com/${repoConfig.repo}/compare/${baseBranch}...${updateBranch}?${params.toString()}`;
return { repoName, title, description, url };
}
function printPullRequestInfo(prInfo: PullRequestInfo): void {
console.log('');
console.log(`📦 ${prInfo.repoName}`);
console.log(` Title: ${prInfo.title}`);
console.log(` Description: ${prInfo.description}`);
console.log(` Open PR: ${prInfo.url}`);
}
async function checkRequiredTools(): Promise<void> {
try {
execSync('gh --version', { stdio: 'ignore' });
} catch {
throw new Error(
'GitHub CLI (gh) is required but not installed. Please install gh to continue.'
);
}
}
async function setupRepository(
repoName: string,
repoIdentifier: string,
repoBranch: string
): Promise<void> {
const cloneDir = path.join(REPOS_DIR, repoName);
log(`Setting up repository: ${repoName}...`);
// If directory already exists, assume it's already setup and skip
if (fs.existsSync(cloneDir)) {
log(`Repository ${repoName} already exists at ${cloneDir}, skipping setup`);
return;
}
try {
// Clone repository using gh cli with specific branch
log(
`Cloning ${repoName} from ${repoIdentifier} (branch: ${repoBranch})...`
);
await execAsync(
`gh repo clone "${repoIdentifier}" "${cloneDir}" -- --depth 1 --branch "${repoBranch}"`
);
log(`Repository ${repoName} cloned successfully at ${cloneDir}`);
} catch (error) {
throw new Error(
`Failed to setup repository ${repoName}: ${getErrorMessage(error)}`
);
}
}
async function updateRepository(repoName: string): Promise<PullRequestInfo> {
const repoDir = path.join(REPOS_DIR, repoName);
log(`Starting update for repository: ${repoName}`);
// Get configuration for this repo
const config: Config = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
const repoConfig = config.repositories[repoName];
// Check required tools
await checkRequiredTools();
// Create repos directory if it doesn't exist
if (!fs.existsSync(REPOS_DIR)) {
log(`Creating repos directory: ${REPOS_DIR}`);
fs.mkdirSync(REPOS_DIR, { recursive: true });
}
// Setup repository if it doesn't exist (noop if already exists)
await setupRepository(repoName, repoConfig.repo, repoConfig.branch);
// Verify repository directory exists after setup
if (!fs.existsSync(repoDir)) {
throw new Error(
`Repository directory still not found after setup: ${repoDir}`
);
}
try {
const mainBranch = repoConfig.branch;
// Fetch latest changes from remote
await execWithOutput(
'git fetch origin',
repoDir,
'Fetching latest changes from remote'
);
// Create and checkout update branch from remote main branch
const updateBranch = 'upnx';
await execWithOutput(
`git checkout -B ${updateBranch} origin/${mainBranch}`,
repoDir,
`Creating update branch '${updateBranch}' from origin/${mainBranch}`
);
// Trust mise config if present so correct tool versions are used
const miseToml = path.join(repoDir, 'mise.toml');
const dotMiseToml = path.join(repoDir, '.mise.toml');
if (fs.existsSync(miseToml) || fs.existsSync(dotMiseToml)) {
await execWithOutput(
'mise trust',
repoDir,
'Trusting mise configuration'
);
await execWithOutput(
'mise install',
repoDir,
'Installing mise-managed tools'
);
}
// Detect package manager
const detectedPm = detectPackageManager(repoDir);
log(`🔍 Detected package manager: ${detectedPm}`);
const packageManager =
(repoConfig?.packageManager as PackageManager) || detectedPm;
log(`⚙️ Using package manager: ${packageManager}`);
// Install dependencies
const installCmd = getInstallCommand(packageManager);
await execWithOutput(installCmd, repoDir, 'Installing dependencies');
// Get initial Nx version before migration
const fromVersion = await getNxVersion(repoDir, packageManager);
log(`📦 Current Nx version: ${fromVersion}`);
// Run nx migrate with the next version of the migrate CLI itself
const migrateCmd = getMigrateCommand(packageManager);
await execWithOutput(
migrateCmd,
repoDir,
'Running Nx migration to next version',
{ NX_MIGRATE_CLI_VERSION: 'next' }
);
// Install updated dependencies to get the new Nx version
await execWithOutput(
installCmd,
repoDir,
'Installing updated dependencies with new Nx version'
);
// Check if migrations.json was created
const migrationsFile = path.join(repoDir, 'migrations.json');
let toVersion: string;
// Get the updated Nx version after installing new dependencies
toVersion = await getNxVersion(repoDir, packageManager);
log(`📦 Updated Nx version: ${toVersion}`);
// Commit the initial migration setup (package.json, migrations.json)
await execWithOutput('git add .', repoDir, 'Staging migration setup files');
await execWithOutput(
`git commit -m "chore(repo): update nx to ${toVersion}"`,
repoDir,
'Committing migration setup'
);
if (fs.existsSync(migrationsFile)) {
log(
'📋 migrations.json found, running migrations with automatic commits...'
);
// Run migrations with --create-commits flag (Nx will create commits automatically)
const runMigrationsCmd = getRunMigrationsCommand(packageManager);
await execWithOutput(
runMigrationsCmd,
repoDir,
'Applying Nx migrations (auto-commits enabled)',
{ NX_MIGRATE_CLI_VERSION: 'next' }
);
// Clean up migrations.json after successful migration
if (fs.existsSync(migrationsFile)) {
log('🧹 Cleaning up migrations.json after successful migration');
fs.unlinkSync(migrationsFile);
// Commit the removal of migrations.json if there are any changes
try {
await execWithOutput(
'git add .',
repoDir,
'Staging migration cleanup'
);
await execWithOutput(
'git commit -m "chore(repo): clean up migrations.json after migration"',
repoDir,
'Committing migration cleanup'
);
} catch (error) {
// It's okay if there's nothing to commit
log('📋 No additional cleanup needed');
}
}
log('✅ Nx migrations completed with automatic commits');
} else {
log('📋 No migrations.json found, migration setup complete');
}
// Run post-nx-update script if it exists
await runPostUpdateScript(repoDir, packageManager);
// Reset Nx cache to avoid prepush hook issues
const resetCmd = getResetCommand(packageManager);
await execWithOutput(
resetCmd,
repoDir,
'Resetting Nx cache to avoid prepush hook issues'
);
// Push the update branch first
await execWithOutput(
`git push -u origin ${updateBranch} --force --no-verify`,
repoDir,
'Pushing update branch to remote (force, skipping hooks)'
);
// Build the pull request URL (PRs are created manually from this URL)
const prInfo = buildPullRequestInfo(
repoName,
updateBranch,
fromVersion,
toVersion
);
log(`✅ Update completed successfully for ${repoName}`);
return prInfo;
} catch (error) {
throw new Error(`Failed to update ${repoName}: ${getErrorMessage(error)}`);
}
}
function printPullRequestSummary(prInfos: PullRequestInfo[]): void {
if (prInfos.length === 0) {
return;
}
console.log('');
console.log('═══════════════════════════════════════════════');
console.log(' Pull request(s) to create');
console.log('═══════════════════════════════════════════════');
prInfos.forEach(printPullRequestInfo);
console.log('');
}
async function updateAllRepositories(): Promise<void> {
const config: Config = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
const repoNames = Object.keys(config.repositories);
log('Starting concurrent updates for all repositories...');
const results = await Promise.allSettled(
repoNames.map((repoName) => updateRepository(repoName))
);
const prInfos: PullRequestInfo[] = [];
const failures: string[] = [];
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
prInfos.push(result.value);
} else {
const repoName = repoNames[index];
log(`ERROR updating ${repoName}: ${getErrorMessage(result.reason)}`);
failures.push(repoName);
}
});
// Print the PR URLs for the repos that updated successfully.
printPullRequestSummary(prInfos);
if (failures.length > 0) {
throw new Error(`Failed to update: ${failures.join(', ')}`);
}
log('All repositories updated successfully!');
}
async function main(): Promise<void> {
const repoName = process.argv[2];
try {
// Check if config file exists
if (!fs.existsSync(CONFIG_FILE)) {
throw new Error(`Configuration file not found at ${CONFIG_FILE}`);
}
const config: Config = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
if (!repoName) {
// No repo specified, update all repositories concurrently
await updateAllRepositories();
} else {
// Check if repository exists in config
if (!config.repositories[repoName]) {
log(`ERROR: Repository '${repoName}' not found in configuration`);
log('Available repositories:');
Object.keys(config.repositories).forEach((name) => log(` - ${name}`));
process.exit(1);
}
const prInfo = await updateRepository(repoName);
printPullRequestSummary([prInfo]);
}
} catch (error) {
log(`ERROR: ${getErrorMessage(error)}`);
process.exit(1);
}
}
if (require.main === module) {
main();
}
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.base.json",
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
}
],
"nx": {
"addTypecheckTarget": false
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"declaration": true,
"types": ["node"],
"composite": true,
"esModuleInterop": true,
"resolveJsonModule": true
},
"include": ["src/**/*.ts", "config/**/*"],
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
}
+11
View File
@@ -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).
+29
View File
@@ -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,
},
},
];
+9
View File
@@ -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"
}
}
}
+14
View File
@@ -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"
}
}
}
+11
View File
@@ -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,
};
+21
View File
@@ -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": {}
}
+10
View File
@@ -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: ![alt](path)
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
}
@@ -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": []
}
View File
@@ -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
);
},
];
+16
View File
@@ -0,0 +1,16 @@
{
"extends": "../../tsconfig.base.json",
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
],
"nx": {
"addTypecheckTarget": false
}
}
+21
View File
@@ -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": []
}
+28
View File
@@ -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"
}
]
}