diff --git a/packages/agent-cli/README.md b/packages/agent-cli/README.md index 9b603cb53..41b4d35dd 100644 --- a/packages/agent-cli/README.md +++ b/packages/agent-cli/README.md @@ -23,6 +23,10 @@ pnpm add -D @rsdoctor/agent-cli The package exposes a binary named `rsdoctor-agent`. +## Artifact compatibility + +The datasource accepts both legacy `{ data, clientRoutes }` artifacts and artifacts with the optional versioned top-level `metadata` field. For metadata-aware consumers, a section marked `collected` was collected even when its payload is empty; a section marked `omitted` retains the legacy placeholder or `undefined` payload and includes the reason it was not collected. In-process tools that require an omitted section return `ok: false` with a structured `RSDOCTOR_SECTION_UNAVAILABLE` error instead of reporting an empty success. Legacy artifacts without section metadata keep their existing behavior. + ## Usage ```bash diff --git a/packages/agent-cli/src/commands/datasource.ts b/packages/agent-cli/src/commands/datasource.ts index aec2a2a2e..223fe1de0 100644 --- a/packages/agent-cli/src/commands/datasource.ts +++ b/packages/agent-cli/src/commands/datasource.ts @@ -55,7 +55,19 @@ interface RsdoctorError { packages?: unknown[]; } +export interface RsdoctorArtifactMetadata { + schemaVersion: number; + sections?: Record< + string, + { status: 'collected' } | { status: 'omitted'; reason: string } + >; + [key: string]: unknown; +} + export interface RsdoctorData { + /** Absent on legacy artifacts; unknown fields are preserved for newer schemas. */ + metadata?: RsdoctorArtifactMetadata; + clientRoutes?: string[]; data?: { chunkGraph?: { chunks?: Array<{ diff --git a/packages/agent-cli/src/commands/datasource/tree-shaking.ts b/packages/agent-cli/src/commands/datasource/tree-shaking.ts index bc75a7c64..40ac89efb 100644 --- a/packages/agent-cli/src/commands/datasource/tree-shaking.ts +++ b/packages/agent-cli/src/commands/datasource/tree-shaking.ts @@ -16,10 +16,7 @@ interface BailoutModule { } export type SideEffectsCategory = - | 'cjs' - | 'barrel' - | 'side-effects' - | 'dynamic-import'; + 'cjs' | 'barrel' | 'side-effects' | 'dynamic-import'; export type RetainedModuleCategory = SideEffectsCategory | 'unknown'; @@ -84,11 +81,27 @@ function getBailoutModules( .filter(Boolean); return modules - .filter((module) => module.bailoutReason) + .filter((module) => hasBailoutReasonContent(module.bailoutReason)) .filter((module) => matchesModuleFilters(module, normalizedFilters)) .map((module) => toBailoutModule(module)); } +function hasBailoutReasonContent(value: unknown): boolean { + if (typeof value === 'string') { + return value.trim().length > 0; + } + if (typeof value === 'number' || typeof value === 'boolean') { + return true; + } + if (Array.isArray(value)) { + return value.some((item) => hasBailoutReasonContent(item)); + } + if (value && typeof value === 'object') { + return Object.values(value).some((item) => hasBailoutReasonContent(item)); + } + return false; +} + function getRetainedModuleCategory( bailoutReason: unknown, ): RetainedModuleCategory { @@ -315,7 +328,7 @@ export function getSideEffects( name, count: stats.count, totalSize: stats.totalSize, - modules: stats.modules, + modules: paginateItems(stats.modules, pageNumber, pageSize).items, })) .sort((a, b) => b.totalSize - a.totalSize); diff --git a/packages/agent-cli/src/commands/handlers/assets.ts b/packages/agent-cli/src/commands/handlers/assets.ts index 50d2d7e03..a4e1f228e 100644 --- a/packages/agent-cli/src/commands/handlers/assets.ts +++ b/packages/agent-cli/src/commands/handlers/assets.ts @@ -277,13 +277,18 @@ export async function diffAssets( }; } -export async function getMediaAssets(): Promise<{ +export async function getMediaAssets( + limit: number = Number.MAX_SAFE_INTEGER, +): Promise<{ ok: boolean; data: { guidance: string; chunks: unknown }; description: string; }> { - const chunksResult = getChunks(1, Number.MAX_SAFE_INTEGER); - const chunks = chunksResult.items || []; + const chunksResult = getChunks(1, limit); + const chunks = (chunksResult.items || []).map((chunk) => ({ + ...chunk, + assets: chunk.assets.slice(0, limit), + })); return { ok: true, data: { diff --git a/packages/agent-cli/src/commands/handlers/build.ts b/packages/agent-cli/src/commands/handlers/build.ts index 5237b553e..9e51fe2c0 100644 --- a/packages/agent-cli/src/commands/handlers/build.ts +++ b/packages/agent-cli/src/commands/handlers/build.ts @@ -13,6 +13,13 @@ import { getTreeShakingSummary } from './tree-shaking'; interface Chunk { size: number; + assets?: unknown[]; +} + +function limitChunkAssets(chunk: Chunk, limit: number): Chunk { + return chunk.assets + ? { ...chunk, assets: chunk.assets.slice(0, limit) } + : chunk; } function withoutDescription(result: { ok: boolean; data: T }): { @@ -64,7 +71,7 @@ export async function getConfig(): Promise<{ }; } -async function executeStep1(): Promise<{ +async function executeStep1(limit: number): Promise<{ duplicatePackages: { ok: boolean; data: unknown }; similarPackages: { ok: boolean; data: unknown }; mediaAssets: { ok: boolean; data: unknown }; @@ -73,12 +80,13 @@ async function executeStep1(): Promise<{ const [duplicatePackages, similarPackages, mediaAssets] = await Promise.all([ detectDuplicatePackages(), detectSimilarPackages(), - getMediaAssets(), + getMediaAssets(limit), ]); const chunksResult = getChunks(1, Number.MAX_SAFE_INTEGER); const chunks = chunksResult.items || []; const chunksArray = chunks as Chunk[]; + const largeChunks = getLargeChunksData(chunksArray); return { duplicatePackages: omitModulesFields(withoutDescription(duplicatePackages)), @@ -86,20 +94,28 @@ async function executeStep1(): Promise<{ mediaAssets: omitModulesFields(withoutDescription(mediaAssets)), largeChunks: omitModulesFields({ ok: true, - data: getLargeChunksData(chunksArray), + data: { + ...largeChunks, + oversized: largeChunks.oversized + .slice(0, limit) + .map((chunk) => limitChunkAssets(chunk, limit)), + }, }), }; } export async function optimizeBundle( stepInput?: string, + limitInput?: string, ): Promise<{ ok: boolean; data: unknown; description: string }> { const step = stepInput ? parsePositiveInt(stepInput, 'step', { min: 1, max: 2 }) : undefined; + const limit = + parsePositiveInt(limitInput, 'limit', { min: 1, max: 1000 }) ?? 100; if (step === 1) { - const step1Data = await executeStep1(); + const step1Data = await executeStep1(limit); return { ok: true, data: { @@ -128,7 +144,7 @@ export async function optimizeBundle( } const [step1Data, treeShakingSummary] = await Promise.all([ - executeStep1(), + executeStep1(limit), getTreeShakingSummary(), ]); diff --git a/packages/agent-cli/src/commands/router.ts b/packages/agent-cli/src/commands/router.ts index b11ccbeb0..b7f8067c4 100644 --- a/packages/agent-cli/src/commands/router.ts +++ b/packages/agent-cli/src/commands/router.ts @@ -73,6 +73,16 @@ const optimizeStepOptions: OptionDef[] = [ type: 'integer', enum: [1, 2], }, + { + name: '--limit', + description: + 'Maximum detailed rows per bundle analysis section (default: 100, max: 1000).', + required: false, + type: 'integer', + default: 100, + minimum: 1, + maximum: 1000, + }, ]; const pageNumberOption: OptionDef = { @@ -122,7 +132,8 @@ function createOptimizeCommand( return { ...command, options: optimizeStepOptions, - handler: (opts) => optimizeBundle(opts['step'] as string), + handler: (opts) => + optimizeBundle(opts['step'] as string, opts.limit as string), }; } @@ -613,12 +624,23 @@ const toolInputSchema = { minimum: 1, description: 'Optional page number for response pagination.', }, + pageNumber: { + type: 'integer', + minimum: 1, + description: 'Alias for page.', + }, pageSize: { type: 'integer', minimum: 1, maximum: 1000, description: 'Optional page size for response pagination.', }, + limit: { + type: 'integer', + minimum: 1, + maximum: 1000, + description: 'Alias for pageSize and a bound for aggregate tool details.', + }, } as Record, additionalProperties: true, }; diff --git a/packages/agent-cli/src/core/result-controls.ts b/packages/agent-cli/src/core/result-controls.ts index 5dc15bf16..62a2bc63c 100644 --- a/packages/agent-cli/src/core/result-controls.ts +++ b/packages/agent-cli/src/core/result-controls.ts @@ -10,7 +10,7 @@ interface ParsedControls { paginateResult: boolean; } -const CONTROL_KEYS = new Set(['filter', 'page', 'pageSize']); +const CONTROL_KEYS = new Set(['filter', 'page', 'pageNumber', 'pageSize']); function parsePositiveInteger( value: unknown, @@ -223,10 +223,15 @@ export function splitToolInputControls( }; }, ): ParsedControls { + const pageInput = input.page ?? input.pageNumber; + const pageSizeInput = input.pageSize ?? input.limit; + const pageSize = parsePositiveInteger(pageSizeInput, 'pageSize'); const controls: ToolResultControls = { filterPaths: parseFilterPaths(input.filter), - page: parsePositiveInteger(input.page, 'page'), - pageSize: parsePositiveInteger(input.pageSize, 'pageSize'), + page: + parsePositiveInteger(pageInput, 'page') ?? + (pageSize !== undefined ? 1 : undefined), + pageSize, }; const passthroughInput: Record = {}; diff --git a/packages/agent-cli/src/executor.ts b/packages/agent-cli/src/executor.ts index 1d1eb32b4..ddb5ce2f2 100644 --- a/packages/agent-cli/src/executor.ts +++ b/packages/agent-cli/src/executor.ts @@ -11,9 +11,57 @@ import { splitToolInputControls, } from './core/result-controls'; import { getInProcessToolExecutors } from './commands'; +import { loadJsonData } from './commands/datasource'; const execFileAsync = promisify(execFile); +const TOOL_REQUIRED_SECTIONS: Record = { + build_summary: ['summary'], + chunks_list: ['chunkGraph'], + errors_list: ['errors'], + packages_direct_dependencies: ['packageGraph'], + packages_duplicates: ['errors'], + packages_similar: ['packageGraph'], + tree_shaking_retained_modules: ['moduleGraph'], + tree_shaking_side_effects: ['moduleGraph'], + tree_shaking_summary: ['errors'], +}; + +function getToolRequiredSections( + toolName: string, + input: Record, +): string[] { + if (toolName === 'bundle_optimize') { + return input.step === 2 || input.step === '2' + ? ['errors'] + : ['errors', 'packageGraph', 'chunkGraph']; + } + return TOOL_REQUIRED_SECTIONS[toolName] ?? []; +} + +function getUnavailableSectionResult( + toolName: string, + input: Record, + dataFile: string, +): unknown { + const sections = loadJsonData(dataFile).metadata?.sections; + for (const section of getToolRequiredSections(toolName, input)) { + const state = sections?.[section]; + if (state?.status === 'omitted') { + return { + ok: false, + error: { + code: 'RSDOCTOR_SECTION_UNAVAILABLE', + message: `Rsdoctor artifact section "${section}" is unavailable (${state.reason}).`, + section, + status: state.status, + reason: state.reason, + }, + }; + } + } +} + async function defaultRunCommand(command: string[]): Promise { const [file, ...args] = command; const { stdout } = await execFileAsync(file, args, { @@ -83,6 +131,14 @@ export function createInProcessRsdoctorCliToolExecutor(): ToolExecutor { splitToolInputControls(request.input, { sourcePagination: tool.sourcePagination, }); + const unavailableSectionResult = getUnavailableSectionResult( + request.toolName, + request.input, + request.dataFile, + ); + if (unavailableSectionResult) { + return unavailableSectionResult; + } const result = await tool.execute({ dataFile: request.dataFile, input: passthroughInput, diff --git a/packages/agent-cli/tests/catalog.test.ts b/packages/agent-cli/tests/catalog.test.ts index 4635b2116..125484235 100644 --- a/packages/agent-cli/tests/catalog.test.ts +++ b/packages/agent-cli/tests/catalog.test.ts @@ -42,6 +42,28 @@ describe('tool catalog', () => { ]); }); + it('passes bundle output limits into built commands', () => { + const bundleOptimize = getToolCatalog().find( + (tool) => tool.name === 'bundle_optimize', + ); + + expect( + bundleOptimize?.buildCommand({ + dataFile: '/tmp/rsdoctor-data.json', + input: { limit: 2 }, + }), + ).toEqual([ + 'rsdoctor-agent', + 'bundle', + 'optimize', + '--data-file', + '/tmp/rsdoctor-data.json', + '--compact', + '--limit', + '2', + ]); + }); + it('passes tool-specific input into built commands', () => { const catalog = getToolCatalog(); const sideEffects = catalog.find( @@ -64,4 +86,20 @@ describe('tool catalog', () => { 'cjs', ]); }); + + it('declares the pagination aliases accepted by catalog tools', () => { + const [tool] = getToolCatalog(); + + expect(tool.inputSchema.properties).toMatchObject({ + limit: { + type: 'integer', + minimum: 1, + maximum: 1000, + }, + pageNumber: { + type: 'integer', + minimum: 1, + }, + }); + }); }); diff --git a/packages/agent-cli/tests/rsdoctor-cli.test.ts b/packages/agent-cli/tests/rsdoctor-cli.test.ts index aea667b2e..d4364fec0 100644 --- a/packages/agent-cli/tests/rsdoctor-cli.test.ts +++ b/packages/agent-cli/tests/rsdoctor-cli.test.ts @@ -12,7 +12,286 @@ import { } from '../src/executor'; import { runCli } from '../src/cli'; +function writeModuleGraphArtifact(modules: Array>): { + dataFile: string; + tempDir: string; +} { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-cli-')); + const dataFile = path.join(tempDir, 'rsdoctor-data.json'); + fs.writeFileSync( + dataFile, + JSON.stringify({ data: { moduleGraph: { modules } } }), + ); + return { dataFile, tempDir }; +} + describe('rsdoctor cli tool executor', () => { + it('parses legacy and v1 artifacts without changing report data', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-cli-data-')); + const legacyFile = path.join(tempDir, 'legacy.json'); + const v1File = path.join(tempDir, 'v1.json'); + const reportData = { + summary: { costs: [] }, + moduleGraph: { modules: [], dependencies: [], exports: [] }, + }; + fs.writeFileSync(legacyFile, JSON.stringify({ data: reportData })); + fs.writeFileSync( + v1File, + JSON.stringify({ + data: reportData, + metadata: { + schemaVersion: 1, + producer: { name: '@rsdoctor/core', version: '2.0.0-beta.0' }, + futureField: { preserved: true }, + }, + }), + ); + + try { + const legacy = datasource.loadJsonData(legacyFile); + const v1 = datasource.loadJsonData(v1File); + + expect(legacy.data).toEqual(reportData); + expect(legacy.metadata).toBeUndefined(); + expect(v1.data).toEqual(reportData); + expect(v1.metadata).toEqual({ + schemaVersion: 1, + producer: { name: '@rsdoctor/core', version: '2.0.0-beta.0' }, + futureField: { preserved: true }, + }); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('keeps collected-but-empty package graph results successful', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-cli-')); + const dataFile = path.join(tempDir, 'rsdoctor-data.json'); + fs.writeFileSync( + dataFile, + JSON.stringify({ + metadata: { + schemaVersion: 1, + sections: { + packageGraph: { status: 'collected' }, + }, + }, + data: { + packageGraph: { packages: [], dependencies: [] }, + }, + }), + ); + + const executor = createInProcessRsdoctorCliToolExecutor(); + + try { + await expect( + executor.execute({ + toolName: 'packages_direct_dependencies', + input: {}, + dataFile, + }), + ).resolves.toMatchObject({ + ok: true, + data: { total: 0, items: [] }, + }); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('reports omitted module graph data as unavailable to tree-shaking tools', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-cli-')); + const dataFile = path.join(tempDir, 'rsdoctor-data.json'); + fs.writeFileSync( + dataFile, + JSON.stringify({ + metadata: { + schemaVersion: 1, + sections: { + moduleGraph: { status: 'omitted', reason: 'not-selected' }, + }, + }, + data: { + moduleGraph: { modules: [], dependencies: [], exports: [] }, + }, + }), + ); + + const executor = createInProcessRsdoctorCliToolExecutor(); + + try { + await expect( + executor.execute({ + toolName: 'tree_shaking_retained_modules', + input: {}, + dataFile, + }), + ).resolves.toEqual({ + ok: false, + error: { + code: 'RSDOCTOR_SECTION_UNAVAILABLE', + message: + 'Rsdoctor artifact section "moduleGraph" is unavailable (not-selected).', + section: 'moduleGraph', + status: 'omitted', + reason: 'not-selected', + }, + }); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('reports an uncollected package graph instead of zero packages', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-cli-')); + const dataFile = path.join(tempDir, 'rsdoctor-data.json'); + fs.writeFileSync( + dataFile, + JSON.stringify({ + metadata: { + schemaVersion: 1, + sections: { + packageGraph: { status: 'omitted', reason: 'not-collected' }, + }, + }, + data: { + packageGraph: { packages: [], dependencies: [] }, + }, + }), + ); + + const executor = createInProcessRsdoctorCliToolExecutor(); + + try { + await expect( + executor.execute({ + toolName: 'packages_direct_dependencies', + input: {}, + dataFile, + }), + ).resolves.toEqual({ + ok: false, + error: { + code: 'RSDOCTOR_SECTION_UNAVAILABLE', + message: + 'Rsdoctor artifact section "packageGraph" is unavailable (not-collected).', + section: 'packageGraph', + status: 'omitted', + reason: 'not-collected', + }, + }); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('guards every catalog tool when its required artifact section is omitted', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-cli-')); + const dataFile = path.join(tempDir, 'rsdoctor-data.json'); + const requiredSectionByTool = { + build_summary: 'summary', + bundle_optimize: 'errors', + chunks_list: 'chunkGraph', + errors_list: 'errors', + packages_direct_dependencies: 'packageGraph', + packages_duplicates: 'errors', + packages_similar: 'packageGraph', + tree_shaking_retained_modules: 'moduleGraph', + tree_shaking_side_effects: 'moduleGraph', + tree_shaking_summary: 'errors', + } as const; + const sections = Object.fromEntries( + [...new Set(Object.values(requiredSectionByTool))].map((section) => [ + section, + { status: 'omitted', reason: 'not-selected' }, + ]), + ); + fs.writeFileSync( + dataFile, + JSON.stringify({ + metadata: { schemaVersion: 1, sections }, + data: { + chunkGraph: { assets: [], chunks: [], entrypoints: [] }, + errors: [], + moduleGraph: { modules: [], dependencies: [], exports: [] }, + packageGraph: { packages: [], dependencies: [] }, + summary: {}, + }, + }), + ); + + const executor = createInProcessRsdoctorCliToolExecutor(); + + try { + expect(Object.keys(requiredSectionByTool).sort()).toEqual( + getToolCatalog() + .map((tool) => tool.name) + .sort(), + ); + for (const [toolName, section] of Object.entries(requiredSectionByTool)) { + await expect( + executor.execute({ toolName, input: {}, dataFile }), + ).resolves.toMatchObject({ + ok: false, + error: { code: 'RSDOCTOR_SECTION_UNAVAILABLE', section }, + }); + } + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('guards only the sections used by the selected bundle optimization step', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-cli-')); + const dataFile = path.join(tempDir, 'rsdoctor-data.json'); + fs.writeFileSync( + dataFile, + JSON.stringify({ + metadata: { + schemaVersion: 1, + sections: { + chunkGraph: { status: 'omitted', reason: 'not-selected' }, + errors: { status: 'collected' }, + packageGraph: { status: 'omitted', reason: 'not-collected' }, + }, + }, + data: { + chunkGraph: { assets: [], chunks: [], entrypoints: [] }, + errors: [], + packageGraph: { packages: [], dependencies: [] }, + }, + }), + ); + + const executor = createInProcessRsdoctorCliToolExecutor(); + + try { + await expect( + executor.execute({ + toolName: 'bundle_optimize', + input: { step: 1 }, + dataFile, + }), + ).resolves.toMatchObject({ + ok: false, + error: { + code: 'RSDOCTOR_SECTION_UNAVAILABLE', + section: 'packageGraph', + }, + }); + await expect( + executor.execute({ + toolName: 'bundle_optimize', + input: { step: 2 }, + dataFile, + }), + ).resolves.toMatchObject({ ok: true }); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + it('runs the mapped command and returns parsed json', async () => { const commands: string[][] = []; const catalog = getToolCatalog(); @@ -153,6 +432,172 @@ describe('rsdoctor cli tool executor', () => { }); }); + it('excludes modules whose bailout reason has no content', async () => { + const { dataFile, tempDir } = writeModuleGraphArtifact([ + { id: 1, path: '/repo/src/a.ts', bailoutReason: [] }, + { id: 2, path: '/repo/src/b.ts', bailoutReason: {} }, + { + id: 3, + path: '/repo/src/c.ts', + bailoutReason: ['Statement with side_effects in source code'], + }, + ]); + const executor = createInProcessRsdoctorCliToolExecutor(); + + try { + const result = (await executor.execute({ + toolName: 'tree_shaking_side_effects', + input: {}, + dataFile, + })) as { data: { all: Array<{ id: number }>; total: number } }; + + expect(result.data.total).toBe(1); + expect(result.data.all.map((module) => module.id)).toEqual([3]); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('uses limit to bound every side-effects module collection', async () => { + const { dataFile, tempDir } = writeModuleGraphArtifact([ + { + id: 1, + path: '/repo/node_modules/pkg/a.js', + bailoutReason: ['side effects'], + }, + { + id: 2, + path: '/repo/node_modules/pkg/b.js', + bailoutReason: ['side effects'], + }, + { + id: 3, + path: '/repo/node_modules/pkg/c.js', + bailoutReason: ['side effects'], + }, + ]); + const executor = createInProcessRsdoctorCliToolExecutor(); + + try { + const result = (await executor.execute({ + toolName: 'tree_shaking_side_effects', + input: { limit: 1 }, + dataFile, + })) as { + data: { + all: Array<{ id: number }>; + nodeModules: { + topPackages: Array<{ modules: Array<{ id: number }> }>; + }; + pageNumber: number; + pageSize: number; + total: number; + }; + }; + + expect(result.data).toMatchObject({ + total: 3, + pageNumber: 1, + pageSize: 1, + }); + expect(result.data.all.map((module) => module.id)).toEqual([1]); + expect( + result.data.nodeModules.topPackages.flatMap((pkg) => pkg.modules), + ).toEqual([expect.objectContaining({ id: 1 })]); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('accepts pageNumber when selecting a later source page', async () => { + const { dataFile, tempDir } = writeModuleGraphArtifact([ + { id: 1, path: '/repo/src/a.ts', bailoutReason: ['side effects'] }, + { id: 2, path: '/repo/src/b.ts', bailoutReason: ['side effects'] }, + ]); + const executor = createInProcessRsdoctorCliToolExecutor(); + + try { + const result = (await executor.execute({ + toolName: 'tree_shaking_side_effects', + input: { limit: 1, pageNumber: 2 }, + dataFile, + })) as { + data: { + all: Array<{ id: number }>; + pageNumber: number; + pageSize: number; + }; + }; + + expect(result.data.pageNumber).toBe(2); + expect(result.data.pageSize).toBe(1); + expect(result.data.all.map((module) => module.id)).toEqual([2]); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('bounds bundle optimization chunk details with limit', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-cli-')); + const dataFile = path.join(tempDir, 'rsdoctor-data.json'); + fs.writeFileSync( + dataFile, + JSON.stringify({ + data: { + chunkGraph: { + chunks: [ + { id: 1, name: 'one', modules: [] }, + { id: 2, name: 'two', modules: [] }, + { id: 3, name: 'three', modules: [] }, + ], + assets: [ + { path: 'one.js', size: 500_000, chunks: [1] }, + { path: 'one.css', size: 500_000, chunks: [1] }, + { path: 'two.js', size: 1_000_000, chunks: [2] }, + { path: 'three.js', size: 3_000_000, chunks: [3] }, + { path: 'three.css', size: 1_000_000, chunks: [3] }, + ], + }, + errors: [], + packageGraph: { packages: [], dependencies: [] }, + }, + }), + ); + const executor = createInProcessRsdoctorCliToolExecutor(); + + try { + const result = (await executor.execute({ + toolName: 'bundle_optimize', + input: { limit: 1, step: 1 }, + dataFile, + })) as { + data: { + largeChunks: { + data: { + oversized: Array<{ assets: unknown[]; id: number }>; + }; + }; + mediaAssets: { + data: { + chunks: Array<{ assets: unknown[]; id: number }>; + }; + }; + }; + }; + + expect( + result.data.mediaAssets.data.chunks.map((chunk) => chunk.id), + ).toEqual([1]); + expect(result.data.mediaAssets.data.chunks[0].assets).toHaveLength(1); + expect(result.data.largeChunks.data.oversized).toEqual([ + expect.objectContaining({ id: 3 }), + ]); + expect(result.data.largeChunks.data.oversized[0].assets).toHaveLength(1); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + it('detects duplicate package rules by code instead of description text', async () => { const spy = rs.spyOn(datasource, 'getRules').mockReturnValue([ { diff --git a/packages/core/src/inner-plugins/plugins/loader.ts b/packages/core/src/inner-plugins/plugins/loader.ts index 1c0fe00ab..b7f3f76bd 100644 --- a/packages/core/src/inner-plugins/plugins/loader.ts +++ b/packages/core/src/inner-plugins/plugins/loader.ts @@ -34,6 +34,8 @@ export class InternalLoaderPlugin< require.resolve('@rsdoctor/core/proxy-loader'); public apply(compiler: T) { + this.sdk.markArtifactSectionCollected?.('loader'); + time('InternalLoaderPlugin.apply'); try { if (compiler.isChild()) { diff --git a/packages/core/src/inner-plugins/plugins/resolver.ts b/packages/core/src/inner-plugins/plugins/resolver.ts index 74589fdc5..2f909cc7c 100644 --- a/packages/core/src/inner-plugins/plugins/resolver.ts +++ b/packages/core/src/inner-plugins/plugins/resolver.ts @@ -13,6 +13,8 @@ export class InternalResolverPlugin< >(); public apply(compiler: T) { + this.sdk.markArtifactSectionCollected?.('resolver'); + // resolver depends on module graph this.scheduler.ensureModulesChunksGraphApplied(compiler); compiler.hooks.normalModuleFactory.tap( diff --git a/packages/core/src/rspack-plugin/plugin.ts b/packages/core/src/rspack-plugin/plugin.ts index 4245c770b..ff97d2a14 100644 --- a/packages/core/src/rspack-plugin/plugin.ts +++ b/packages/core/src/rspack-plugin/plugin.ts @@ -239,7 +239,7 @@ export class RsdoctorRspackPlugin< ...pluginTapPostOptions, stage: pluginTapPostOptions.stage! + 100, }, - () => this.childDone(compiler, context), + (compilation) => this.childDone(compiler, context, compilation.hash), ); } else { compiler.hooks.afterPlugins.tap(pluginTapPostOptions, () => @@ -250,7 +250,7 @@ export class RsdoctorRspackPlugin< ...pluginTapPostOptions, stage: pluginTapPostOptions.stage! + 100, }, - () => this.done(compiler, context), + (stats) => this.done(compiler, context, stats), ); } @@ -332,6 +332,7 @@ export class RsdoctorRspackPlugin< public done = async ( compiler: Plugin.BaseCompilerType<'rspack'>, context = this.getCompilerContext(compiler), + stats?: Plugin.BaseStats, ): Promise => { time('RsdoctorRspackPlugin.done'); try { @@ -343,6 +344,7 @@ export class RsdoctorRspackPlugin< context.sdk.addClientRoutes([ ManifestType.RsdoctorManifestClientRoutes.Overall, ]); + this.setArtifactBuildIdentity(compiler, context, stats?.hash); if (context.sdk instanceof RsdoctorPrimarySDK) { context.sdk.setOutputDir( @@ -631,12 +633,14 @@ export class RsdoctorRspackPlugin< private childDone = async ( compiler: Plugin.BaseCompilerType<'rspack'>, context: RsdoctorCompilerContext, + compilationHash?: string | null, ): Promise => { const bootstrapTask = this.ensureBootstrap(context); await this.awaitBootstrap(context, bootstrapTask); context.sdk.addClientRoutes([ ManifestType.RsdoctorManifestClientRoutes.Overall, ]); + this.setArtifactBuildIdentity(compiler, context, compilationHash); if (context.sdk instanceof RsdoctorPrimarySDK) { context.sdk.setOutputDir( context.sdk.parent.getCompilerOutputDir(context.sdk), @@ -651,6 +655,28 @@ export class RsdoctorRspackPlugin< } }; + private setArtifactBuildIdentity( + compiler: Plugin.BaseCompilerType<'rspack'>, + context: RsdoctorCompilerContext, + compilationHash?: string | null, + ) { + const target = compiler.options.target; + const environment = compiler.name || compiler.options.name; + const identity: Manifest.RsdoctorArtifactCompilationIdentity = {}; + + if (compilationHash) { + identity.compilationHash = compilationHash; + } + if (typeof target === 'string' || Array.isArray(target)) { + identity.target = target; + } + if (environment) { + identity.environment = environment; + } + + context.sdk.setArtifactBuildIdentity?.(identity); + } + private shouldDisposeSDK() { return ( this.options.disableClientServer || diff --git a/packages/core/src/sdk/multiple/primary.ts b/packages/core/src/sdk/multiple/primary.ts index 33c64f16d..8a8834c4a 100644 --- a/packages/core/src/sdk/multiple/primary.ts +++ b/packages/core/src/sdk/multiple/primary.ts @@ -1,4 +1,6 @@ import { Common, Constants, Manifest, SDK } from '@rsdoctor/shared/types'; +import fs from 'node:fs'; +import path from 'node:path'; import { RsdoctorSDK } from '../sdk'; import { RsdoctorSlaveServer } from './server'; import type { RsdoctorSDKController } from './controller'; @@ -87,6 +89,14 @@ export class RsdoctorPrimarySDK return this.parent.master === this; } + public async writeStore(options?: SDK.WriteStoreOptionsType) { + const result = await super.writeStore(options); + if (this.extraConfig?.mode === SDK.IMode[SDK.IMode.brief]) { + await this.parent.refreshManifestSeries(); + } + return result; + } + protected async writePieces( _storeData: Common.PlainObject, options?: SDK.WriteStoreOptionsType, @@ -101,6 +111,7 @@ export class RsdoctorPrimarySDK if (cloudData && parent.isMultiple) { cloudData.name = this.name; cloudData.series = parent.getSeriesData(); + cloudData.metadata = this.getArtifactMetadata('normal'); } const result = await super.writeManifest(); @@ -109,15 +120,62 @@ export class RsdoctorPrimarySDK } async refreshManifestSeries() { - if (!this.parent.isMultiple || !this.cloudData || !this.diskManifestPath) { + if (!this.parent.isMultiple) { return; } + if (this.extraConfig?.mode === SDK.IMode[SDK.IMode.brief]) { + this.refreshBriefArtifactMetadata(); + return; + } + + if (!this.cloudData || !this.diskManifestPath) return; + this.cloudData.name = this.name; this.cloudData.series = this.parent.getSeriesData(); + this.cloudData.metadata = this.getArtifactMetadata('normal'); await super.writeManifest(); } + private refreshBriefArtifactMetadata() { + if (!this.extraConfig?.brief?.type?.includes('json')) return; + + const artifactPath = path.resolve( + this.outputDir, + this.extraConfig.brief.jsonOptions?.fileName ?? 'rsdoctor-data.json', + ); + if (!fs.existsSync(artifactPath)) return; + + const artifact = JSON.parse( + fs.readFileSync(artifactPath, 'utf-8'), + ) as Manifest.RsdoctorBriefArtifact; + artifact.metadata = this.getArtifactMetadata('brief', artifact.data); + fs.writeFileSync(artifactPath, JSON.stringify(artifact)); + } + + public getArtifactMetadata( + mode: Manifest.RsdoctorArtifactOutputMode, + storeData: Partial = this.getStoreData(), + ): Manifest.RsdoctorArtifactMetadata { + const metadata = super.getArtifactMetadata(mode, storeData); + if (!this.parent.isMultiple) { + return metadata; + } + + delete metadata.build.compilationHash; + delete metadata.build.target; + delete metadata.build.environment; + metadata.build.compilers = this.parent.getSeriesData().map((series) => { + const sdk = this.parent.slaves.find((item) => item.name === series.name)!; + return { + name: series.name, + stage: series.stage, + ...sdk.getArtifactBuildIdentity(), + }; + }); + return metadata; + } + getSeriesData(serverUrl = false): Manifest.RsdoctorManifestSeriesData[] { return this.parent.getSeriesData(serverUrl); } diff --git a/packages/core/src/sdk/sdk/core.ts b/packages/core/src/sdk/sdk/core.ts index a962eacf7..26df47853 100644 --- a/packages/core/src/sdk/sdk/core.ts +++ b/packages/core/src/sdk/sdk/core.ts @@ -30,6 +30,12 @@ export abstract class SDKCore protected _envinfo: SDK.EnvInfo = {} as SDK.EnvInfo; + protected _artifactBuildIdentity: Manifest.RsdoctorArtifactCompilationIdentity = + {}; + + protected _artifactCollectedSections = + new Set(); + private _clientRoutes: Set = new Set([ Manifest.RsdoctorManifestClientRoutes.Overall, ]); @@ -108,6 +114,28 @@ export abstract class SDKCore return this.hash; } + public setArtifactBuildIdentity( + identity: Manifest.RsdoctorArtifactCompilationIdentity, + ) { + this._artifactBuildIdentity = { ...identity }; + } + + public getArtifactBuildIdentity() { + return { ...this._artifactBuildIdentity }; + } + + public markArtifactSectionCollected( + section: Manifest.RsdoctorArtifactSectionName, + ) { + this._artifactCollectedSections.add(section); + } + + protected isArtifactSectionCollected( + section: Manifest.RsdoctorArtifactSectionName, + ) { + return this._artifactCollectedSections.has(section); + } + public getClientRoutes() { return [...this._clientRoutes]; } diff --git a/packages/core/src/sdk/sdk/index.ts b/packages/core/src/sdk/sdk/index.ts index 7c2031b17..b4571ad78 100644 --- a/packages/core/src/sdk/sdk/index.ts +++ b/packages/core/src/sdk/sdk/index.ts @@ -2,6 +2,7 @@ import fs from 'node:fs'; import fse from 'fs-extra'; import path from 'path'; import { createRequire } from 'module'; +import packageJson from '../../../package.json'; import { DevToolError } from '@/error'; import { Common, Constants, Manifest, SDK } from '@rsdoctor/shared/types'; import { RawSourceMap, SourceMapConsumer } from 'source-map'; @@ -394,6 +395,109 @@ export class RsdoctorSDK< } } + protected async writePieces( + storeData: Common.PlainObject, + options?: SDK.WriteStoreOptionsType, + ) { + await super.writePieces(storeData, options); + if (this.cloudData) { + this.cloudData.metadata = this.getArtifactMetadata( + 'normal', + storeData as Partial, + ); + } + } + + public getArtifactMetadata( + mode: Manifest.RsdoctorArtifactOutputMode, + storeData: Partial = this.getStoreData(), + ): Manifest.RsdoctorArtifactMetadata { + const briefSections = this.extraConfig?.brief?.jsonOptions?.sections; + const isBriefJson = + mode === 'brief' && this.extraConfig?.brief?.type?.includes('json'); + const compilerConfig = storeData.configs?.[0]; + const buildIdentity = this.getArtifactBuildIdentity(); + const omittedSectionState = ( + reason: Manifest.RsdoctorArtifactOmissionReason, + ): Manifest.RsdoctorArtifactSectionState => ({ + status: 'omitted', + reason, + }); + const sectionState = ( + section: Manifest.RsdoctorArtifactSectionName, + ): Manifest.RsdoctorArtifactSectionState => + storeData[section] === undefined + ? omittedSectionState('not-collected') + : { status: 'collected' }; + const briefSectionState = ( + section: Manifest.RsdoctorArtifactSectionName, + selected: boolean | undefined, + ): Manifest.RsdoctorArtifactSectionState => { + if (isBriefJson && briefSections && !selected) { + return omittedSectionState('not-selected'); + } + return sectionState(section); + }; + + let treeShakingState: Manifest.RsdoctorArtifactSectionState; + if (mode === 'brief') { + treeShakingState = omittedSectionState('output-mode'); + } else if (this.extraConfig?.features?.treeShaking) { + treeShakingState = sectionState('treeShaking'); + } else { + treeShakingState = omittedSectionState('feature-disabled'); + } + + return { + schemaVersion: 1, + producer: { + name: '@rsdoctor/core', + version: packageJson.version, + }, + output: { mode }, + build: { + id: storeData.hash ?? this.getHash(), + root: storeData.root ?? this.root, + ...buildIdentity, + compiler: { + name: this.name, + ...(compilerConfig + ? { + type: compilerConfig.name, + version: String(compilerConfig.version), + } + : {}), + }, + }, + sections: { + errors: briefSectionState('errors', briefSections?.rules), + configs: sectionState('configs'), + summary: sectionState('summary'), + resolver: this.isArtifactSectionCollected('resolver') + ? sectionState('resolver') + : omittedSectionState('feature-disabled'), + loader: this.isArtifactSectionCollected('loader') + ? sectionState('loader') + : omittedSectionState('feature-disabled'), + moduleGraph: briefSectionState( + 'moduleGraph', + briefSections?.moduleGraph, + ), + chunkGraph: briefSectionState('chunkGraph', briefSections?.chunkGraph), + moduleCodeMap: + mode === 'brief' + ? omittedSectionState('output-mode') + : sectionState('moduleCodeMap'), + plugin: sectionState('plugin'), + packageGraph: this._packageGraph + ? { status: 'collected' } + : omittedSectionState('not-collected'), + treeShaking: treeShakingState, + otherReports: sectionState('otherReports'), + }, + }; + } + public async writeStore(options?: SDK.WriteStoreOptionsType) { logger.debug(`sdk.writeStore has run.`, '[SDK.writeStore][end]'); let htmlPath = ''; @@ -408,6 +512,7 @@ export class RsdoctorSDK< const jsonData = { data, clientRoutes, + metadata: this.getArtifactMetadata('brief', data), }; fs.mkdirSync(this.outputDir, { recursive: true }); @@ -546,6 +651,7 @@ export class RsdoctorSDK< return t; }, {} as Common.PlainObject) as unknown as Manifest.RsdoctorManifestWithShardingFiles['data'], + metadata: this.getArtifactMetadata('normal', dataValue), __LOCAL__SERVER__: true, __SOCKET__PORT__: this.server.socketUrl.port.toString(), __SOCKET__URL__: this.server.socketUrl.socketUrl, diff --git a/packages/core/tests/rspack-plugin/plugin.test.ts b/packages/core/tests/rspack-plugin/plugin.test.ts index a7177e22a..a77b61be6 100644 --- a/packages/core/tests/rspack-plugin/plugin.test.ts +++ b/packages/core/tests/rspack-plugin/plugin.test.ts @@ -1,12 +1,78 @@ import { getSDK } from '@/inner-plugins/utils/sdk'; import { RsdoctorRspackPlugin } from '@/rspack-plugin'; +import { getWriteStoreOptions } from '@/rspack-plugin/writeStore'; import { RsdoctorPrimarySDK, RsdoctorSDK } from '@/sdk'; +import { File } from '@/build-utils'; import { RsdoctorServer } from '@/sdk/server'; import type { Plugin } from '@rsdoctor/shared/types'; -import { rspack } from '@rspack/core'; +import { rspack, type MultiCompiler, type MultiStats } from '@rspack/core'; import { afterEach, describe, expect, it, rs } from '@rstest/core'; +import { tmpdir } from 'node:os'; import path from 'node:path'; -import { getWriteStoreOptions } from '@/rspack-plugin/writeStore'; + +rs.setConfig({ testTimeout: 30000 }); + +const createMultiCompiler = ( + testRoot: string, + plugin: RsdoctorRspackPlugin, +) => { + const context = path.resolve(__dirname, '../..'); + const entry = path.resolve( + __dirname, + '../fixtures/default-export/literal/index.js', + ); + + return rspack([ + { + context, + entry, + mode: 'development', + name: 'web', + output: { path: path.join(testRoot, 'web') }, + plugins: [plugin], + target: 'web', + }, + { + context, + entry, + mode: 'development', + name: 'node', + output: { path: path.join(testRoot, 'node') }, + plugins: [plugin], + target: 'node', + }, + ]); +}; + +const runCompiler = (compiler: MultiCompiler) => + new Promise((resolve, reject) => { + compiler.run((error, stats) => { + if (error) { + reject(error); + return; + } + if (!stats) { + reject(new Error('Rspack did not return compilation stats.')); + return; + } + if (stats.hasErrors()) { + reject(new Error(stats.toString({ errors: true }))); + return; + } + resolve(stats); + }); + }); + +const closeCompiler = (compiler: MultiCompiler) => + new Promise((resolve, reject) => { + compiler.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); afterEach(async () => { rs.restoreAllMocks(); @@ -106,6 +172,136 @@ describe('RsdoctorRspackPlugin', () => { await Promise.all([webSDK.dispose(), nodeSDK.dispose()]); }); + it('emits independently matchable identities for multiple compilers', async () => { + const testRoot = path.join( + tmpdir(), + `rsdoctor-multi-compiler-metadata-${Date.now()}`, + ); + const reportDir = path.join(testRoot, 'report'); + const plugin = new RsdoctorRspackPlugin({ + disableClientServer: true, + output: { reportDir }, + }); + const compiler = createMultiCompiler(testRoot, plugin); + + try { + const stats = await runCompiler(compiler); + const observedHashes = Object.fromEntries( + stats.stats.map((item) => [item.compilation.name, item.hash]), + ); + const manifest = JSON.parse( + await File.fse.readFile( + path.join(reportDir, '.rsdoctor', 'manifest.json'), + 'utf-8', + ), + ); + const nodeManifestPath = manifest.series.find( + (item: { name: string }) => item.name === 'node', + ).path; + const nodeManifest = JSON.parse( + await File.fse.readFile(nodeManifestPath, 'utf-8'), + ); + + expect(manifest.metadata.build.compilationHash).toBeUndefined(); + expect(manifest.metadata.build.compilers).toEqual([ + expect.objectContaining({ + name: 'web', + compilationHash: observedHashes.web, + environment: 'web', + target: 'web', + }), + expect.objectContaining({ + name: 'node', + compilationHash: observedHashes.node, + environment: 'node', + target: 'node', + }), + ]); + expect(nodeManifest.metadata.build.compilers).toEqual( + manifest.metadata.build.compilers, + ); + } finally { + await closeCompiler(compiler); + await File.fse.remove(testRoot); + } + }); + + it('refreshes brief JSON identities after sibling and watch completions', async () => { + const testRoot = path.join( + tmpdir(), + `rsdoctor-multi-compiler-brief-metadata-${Date.now()}`, + ); + const reportDir = path.join(testRoot, 'report'); + const plugin = new RsdoctorRspackPlugin({ + disableClientServer: true, + features: { resolver: true }, + output: { + reportDir, + mode: 'brief', + options: { type: ['json'] }, + }, + }); + const compiler = createMultiCompiler(testRoot, plugin); + + try { + const stats = await runCompiler(compiler); + const observedHashes = Object.fromEntries( + stats.stats.map((item) => [item.compilation.name, item.hash]), + ); + const webSDK = plugin.getCompilerSDK('web') as RsdoctorPrimarySDK; + const nodeSDK = plugin.getCompilerSDK('node') as RsdoctorPrimarySDK; + const readArtifact = async (sdk: RsdoctorPrimarySDK) => + JSON.parse( + await File.fse.readFile( + path.join(sdk.outputDir, 'rsdoctor-data.json'), + 'utf-8', + ), + ); + const expectedCompilers = [ + expect.objectContaining({ + name: 'web', + compilationHash: observedHashes.web, + }), + expect.objectContaining({ + name: 'node', + compilationHash: observedHashes.node, + }), + ]; + + expect((await readArtifact(webSDK)).metadata.build.compilers).toEqual( + expectedCompilers, + ); + expect((await readArtifact(nodeSDK)).metadata.build.compilers).toEqual( + expectedCompilers, + ); + expect((await readArtifact(webSDK)).metadata.sections.resolver).toEqual({ + status: 'collected', + }); + expect((await readArtifact(webSDK)).metadata.sections.loader).toEqual({ + status: 'collected', + }); + + webSDK.setArtifactBuildIdentity({ + compilationHash: 'web-watch-hash', + environment: 'web', + target: 'web', + }); + await webSDK.writeStore(); + + expect( + (await readArtifact(nodeSDK)).metadata.build.compilers, + ).toContainEqual( + expect.objectContaining({ + name: 'web', + compilationHash: 'web-watch-hash', + }), + ); + } finally { + await closeCompiler(compiler); + await File.fse.remove(testRoot); + } + }); + it('uses the configured output path before the compiler starts', async () => { const reportDir = path.join(process.cwd(), 'dist', 'brief-report'); const plugin = new RsdoctorRspackPlugin({ diff --git a/packages/core/tests/sdk/sdk/core/brief-json-output.test.ts b/packages/core/tests/sdk/sdk/core/brief-json-output.test.ts index e0b8c9f7e..46d6720b4 100644 --- a/packages/core/tests/sdk/sdk/core/brief-json-output.test.ts +++ b/packages/core/tests/sdk/sdk/core/brief-json-output.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'; import { afterEach, describe, expect, it } from '@rstest/core'; import { File } from '@/build-utils'; import { createSDK, type MockSDKResponse } from '../../utils'; +import packageJson from '../../../../package.json'; describe('brief json output', () => { let target: MockSDKResponse; @@ -32,4 +33,167 @@ describe('brief json output', () => { expect(content).toBe(JSON.stringify(JSON.parse(content))); }); + + it('emits v1 metadata without changing the brief artifact envelope', async () => { + target = await createSDK({ + noServer: true, + mode: 'brief', + brief: { + type: ['json'], + jsonOptions: { + sections: { + chunkGraph: true, + moduleGraph: false, + rules: false, + }, + }, + }, + }); + outputDir = path.resolve(tmpdir(), `rsdoctor_brief_json_${Date.now()}`); + target.sdk.setOutputDir(outputDir); + + await target.sdk.writeStore(); + + const artifact = JSON.parse( + fs.readFileSync(path.join(outputDir, 'rsdoctor-data.json'), 'utf-8'), + ); + + expect(Object.keys(artifact)).toEqual(['data', 'clientRoutes', 'metadata']); + expect(artifact.metadata).toMatchObject({ + schemaVersion: 1, + producer: { + name: '@rsdoctor/core', + version: packageJson.version, + }, + output: { mode: 'brief' }, + build: { + id: artifact.data.hash, + root: artifact.data.root, + compiler: { name: 'test' }, + }, + sections: { + chunkGraph: { status: 'collected' }, + errors: { status: 'omitted', reason: 'not-selected' }, + moduleGraph: { status: 'omitted', reason: 'not-selected' }, + moduleCodeMap: { status: 'omitted', reason: 'output-mode' }, + resolver: { status: 'omitted', reason: 'feature-disabled' }, + treeShaking: { status: 'omitted', reason: 'output-mode' }, + }, + }); + expect(artifact.data.chunkGraph).toEqual({ + assets: [], + chunks: [], + entrypoints: [], + }); + expect(artifact.data.moduleGraph).toEqual({ + dependencies: [], + modules: [], + moduleGraphModules: [], + exports: [], + sideEffects: [], + variables: [], + layers: [], + }); + expect(artifact.data.errors).toEqual([]); + }); + + it('marks an enabled resolver collector as collected when its payload is empty', async () => { + target = await createSDK({ + noServer: true, + mode: 'brief', + brief: { type: ['json'] }, + }); + outputDir = path.resolve(tmpdir(), `rsdoctor_brief_json_${Date.now()}`); + target.sdk.setOutputDir(outputDir); + target.sdk.markArtifactSectionCollected('resolver'); + + await target.sdk.writeStore(); + + const artifact = JSON.parse( + fs.readFileSync(path.join(outputDir, 'rsdoctor-data.json'), 'utf-8'), + ); + + expect(artifact.data.resolver).toEqual([]); + expect(artifact.metadata.sections.resolver).toEqual({ + status: 'collected', + }); + }); + + it('marks a disabled loader collector as omitted', async () => { + target = await createSDK({ + noServer: true, + mode: 'brief', + features: { loader: false }, + brief: { type: ['json'] }, + }); + outputDir = path.resolve(tmpdir(), `rsdoctor_brief_json_${Date.now()}`); + target.sdk.setOutputDir(outputDir); + + await target.sdk.writeStore(); + + const artifact = JSON.parse( + fs.readFileSync(path.join(outputDir, 'rsdoctor-data.json'), 'utf-8'), + ); + + expect(artifact.data.loader).toEqual([]); + expect(artifact.metadata.sections.loader).toEqual({ + status: 'omitted', + reason: 'feature-disabled', + }); + }); + + it('marks an enabled loader collector as collected when its payload is empty', async () => { + target = await createSDK({ + noServer: true, + mode: 'brief', + features: { loader: true }, + brief: { type: ['json'] }, + }); + outputDir = path.resolve(tmpdir(), `rsdoctor_brief_json_${Date.now()}`); + target.sdk.setOutputDir(outputDir); + target.sdk.markArtifactSectionCollected('loader'); + + await target.sdk.writeStore(); + + const artifact = JSON.parse( + fs.readFileSync(path.join(outputDir, 'rsdoctor-data.json'), 'utf-8'), + ); + + expect(artifact.data.loader).toEqual([]); + expect(artifact.metadata.sections.loader).toEqual({ + status: 'collected', + }); + }); + + it('emits v1 metadata on normal manifests without changing sharded data', async () => { + target = await createSDK({ noServer: true }); + outputDir = path.resolve(tmpdir(), `rsdoctor_normal_json_${Date.now()}`); + target.sdk.setOutputDir(outputDir); + + const manifestPath = await target.sdk.writeStore(); + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); + + expect(manifest.client.enableRoutes).toEqual(['Overall']); + expect(manifest.data.summary).toBeInstanceOf(Array); + expect(manifest.metadata).toMatchObject({ + schemaVersion: 1, + producer: { + name: '@rsdoctor/core', + version: packageJson.version, + }, + output: { mode: 'normal' }, + build: { + id: target.sdk.getHash(), + root: target.sdk.root, + compiler: { name: 'test' }, + }, + sections: { + chunkGraph: { status: 'collected' }, + moduleGraph: { status: 'collected' }, + packageGraph: { status: 'omitted', reason: 'not-collected' }, + resolver: { status: 'omitted', reason: 'feature-disabled' }, + treeShaking: { status: 'omitted', reason: 'feature-disabled' }, + }, + }); + }); }); diff --git a/packages/shared/src/types/manifest.ts b/packages/shared/src/types/manifest.ts index 6dadafdd3..4fd6e81dc 100644 --- a/packages/shared/src/types/manifest.ts +++ b/packages/shared/src/types/manifest.ts @@ -3,6 +3,8 @@ import { StoreData } from './sdk'; export interface RsdoctorManifest { client: RsdoctorManifestClient; + /** Optional for compatibility with artifacts produced before schema v1. */ + metadata?: RsdoctorArtifactMetadata; /** * manifest url in tos, used by inner-rsdoctor. */ @@ -20,6 +22,76 @@ export interface RsdoctorManifest { series?: RsdoctorManifestSeriesData[]; } +export type RsdoctorArtifactOutputMode = 'brief' | 'normal'; + +export type RsdoctorArtifactSectionName = + | 'errors' + | 'configs' + | 'summary' + | 'resolver' + | 'loader' + | 'moduleGraph' + | 'chunkGraph' + | 'moduleCodeMap' + | 'plugin' + | 'packageGraph' + | 'treeShaking' + | 'otherReports'; + +export type RsdoctorArtifactOmissionReason = + 'not-selected' | 'output-mode' | 'feature-disabled' | 'not-collected'; + +export type RsdoctorArtifactSectionState = + | { status: 'collected' } + | { status: 'omitted'; reason: RsdoctorArtifactOmissionReason }; + +export type RsdoctorArtifactSections = Record< + RsdoctorArtifactSectionName, + RsdoctorArtifactSectionState +> & + Record; + +export interface RsdoctorArtifactCompilationIdentity { + compilationHash?: string; + target?: string | string[]; + environment?: string; +} + +export interface RsdoctorArtifactCompilerIdentity extends RsdoctorArtifactCompilationIdentity { + name: string; + stage?: number; +} + +export interface RsdoctorArtifactMetadata { + schemaVersion: 1; + producer: { + name: '@rsdoctor/core'; + version: string; + }; + output: { + mode: RsdoctorArtifactOutputMode; + }; + build: RsdoctorArtifactCompilationIdentity & { + /** Existing Rsdoctor SDK/build identifier; not a compilation hash. */ + id: string; + root: string; + compiler: { + name: string; + type?: string; + version?: string; + }; + compilers?: RsdoctorArtifactCompilerIdentity[]; + }; + sections: RsdoctorArtifactSections; +} + +export interface RsdoctorBriefArtifact { + data: RsdoctorManifestData; + clientRoutes: RsdoctorManifestClientRoutes[]; + /** Optional for compatibility with artifacts produced before schema v1. */ + metadata?: RsdoctorArtifactMetadata; +} + export interface RsdoctorManifestSeriesData { name: string; displayName?: string; diff --git a/packages/shared/src/types/sdk/instance.ts b/packages/shared/src/types/sdk/instance.ts index facc5846f..df1ee3b57 100644 --- a/packages/shared/src/types/sdk/instance.ts +++ b/packages/shared/src/types/sdk/instance.ts @@ -6,6 +6,8 @@ import { PluginData } from './plugin'; import { BuilderStoreData, EMOStoreData } from './result'; import { ModuleGraphInstance, ToDataType } from './module'; import { + RsdoctorArtifactCompilationIdentity, + RsdoctorArtifactSectionName, RsdoctorManifestClientRoutes, RsdoctorManifestWithShardingFiles, } from '../manifest'; @@ -114,6 +116,14 @@ export interface RsdoctorSDKInstance { setHash(hash: string): void; getHash(): string; + /** Attach build identity already exposed by the producing compiler. */ + setArtifactBuildIdentity?( + identity: RsdoctorArtifactCompilationIdentity, + ): void; + + /** Mark a section whose collector was installed for this artifact. */ + markArtifactSectionCollected?(section: RsdoctorArtifactSectionName): void; + /** * write the manifest to a folder * - use this.outputDir @@ -130,10 +140,7 @@ export interface IPrintLog { } export type RsdoctorServerCorsStaticOrigin = - | boolean - | string - | RegExp - | Array; + boolean | string | RegExp | Array; export type RsdoctorServerCorsOrigin = | RsdoctorServerCorsStaticOrigin