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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/agent-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions packages/agent-cli/src/commands/datasource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<{
Expand Down
25 changes: 19 additions & 6 deletions packages/agent-cli/src/commands/datasource/tree-shaking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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);

Expand Down
11 changes: 8 additions & 3 deletions packages/agent-cli/src/commands/handlers/assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
26 changes: 21 additions & 5 deletions packages/agent-cli/src/commands/handlers/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(result: { ok: boolean; data: T }): {
Expand Down Expand Up @@ -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 };
Expand All @@ -73,33 +80,42 @@ 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)),
similarPackages: omitModulesFields(withoutDescription(similarPackages)),
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: {
Expand Down Expand Up @@ -128,7 +144,7 @@ export async function optimizeBundle(
}

const [step1Data, treeShakingSummary] = await Promise.all([
executeStep1(),
executeStep1(limit),
getTreeShakingSummary(),
]);

Expand Down
24 changes: 23 additions & 1 deletion packages/agent-cli/src/commands/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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),
};
}

Expand Down Expand Up @@ -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<string, unknown>,
additionalProperties: true,
};
Expand Down
11 changes: 8 additions & 3 deletions packages/agent-cli/src/core/result-controls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, unknown> = {};
Expand Down
56 changes: 56 additions & 0 deletions packages/agent-cli/src/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string[]> = {
Comment thread
ScriptedAlchemy marked this conversation as resolved.
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, unknown>,
): 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<string, unknown>,
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<string> {
const [file, ...args] = command;
const { stdout } = await execFileAsync(file, args, {
Expand Down Expand Up @@ -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,
Expand Down
38 changes: 38 additions & 0 deletions packages/agent-cli/tests/catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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,
},
});
});
});
Loading
Loading