Skip to content
Draft
Show file tree
Hide file tree
Changes from 6 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
39 changes: 39 additions & 0 deletions packages/agent-cli/src/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,41 @@ 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.
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 getUnavailableSectionResult(
toolName: string,
dataFile: string,
): unknown {
const sections = loadJsonData(dataFile).metadata?.sections;
for (const section of TOOL_REQUIRED_SECTIONS[toolName] ?? []) {
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 +115,13 @@ export function createInProcessRsdoctorCliToolExecutor(): ToolExecutor {
splitToolInputControls(request.input, {
sourcePagination: tool.sourcePagination,
});
const unavailableSectionResult = getUnavailableSectionResult(
request.toolName,
request.dataFile,
);
if (unavailableSectionResult) {
return unavailableSectionResult;
}
const result = await tool.execute({
dataFile: request.dataFile,
input: passthroughInput,
Expand Down
160 changes: 160 additions & 0 deletions packages/agent-cli/tests/rsdoctor-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,166 @@ import {
import { runCli } from '../src/cli';

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('runs the mapped command and returns parsed json', async () => {
const commands: string[][] = [];
const catalog = getToolCatalog();
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/inner-plugins/plugins/resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
30 changes: 28 additions & 2 deletions packages/core/src/rspack-plugin/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,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, () =>
Expand All @@ -249,7 +249,7 @@ export class RsdoctorRspackPlugin<
...pluginTapPostOptions,
stage: pluginTapPostOptions.stage! + 100,
},
() => this.done(compiler, context),
(stats) => this.done(compiler, context, stats),
);
}

Expand Down Expand Up @@ -331,6 +331,7 @@ export class RsdoctorRspackPlugin<
public done = async (
compiler: Plugin.BaseCompilerType<'rspack'>,
context = this.getCompilerContext(compiler),
stats?: Plugin.BaseStats,
): Promise<void> => {
time('RsdoctorRspackPlugin.done');
try {
Expand All @@ -342,6 +343,7 @@ export class RsdoctorRspackPlugin<
context.sdk.addClientRoutes([
ManifestType.RsdoctorManifestClientRoutes.Overall,
]);
this.setArtifactBuildIdentity(compiler, context, stats?.hash);

if (context.sdk instanceof RsdoctorPrimarySDK) {
context.sdk.setOutputDir(
Expand Down Expand Up @@ -630,12 +632,14 @@ export class RsdoctorRspackPlugin<
private childDone = async (
compiler: Plugin.BaseCompilerType<'rspack'>,
context: RsdoctorCompilerContext,
compilationHash?: string | null,
): Promise<void> => {
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),
Expand All @@ -650,6 +654,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 ||
Expand Down
Loading
Loading