Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Next Next commit
feat(lib,cli): complete UMS v2.0 migration - remove v1.0 compatibility
Remove all UMS v1.0 backward compatibility code and update tests for v2.0 only.

Breaking Changes:
- ums-lib: Remove v1.0 directive/shape/meta validation
- ums-lib: Remove v1.0 moduleGroups from persona format
- CLI: Remove v1.0 type imports (UMSModule)
- CLI: Remove shape display from inspect command
- CLI: Update test expectations for v2.0 module format
- CLI: Add loadTypeScriptPersona mock for build tests

Test Coverage:
- ums-lib: All 162 tests passing
- CLI: All 201 tests passing
- Total: 363/363 tests passing

Technical Details:
- Use ComponentType enum for type-safe component discrimination
- Add ESLint rule exceptions for runtime validation checks
- Fix nullish coalescing for optional properties
- Use forEach for iteration without unused variables
- Fix ums-mcp ESLint errors (nullish coalescing, catch types)

Files Changed:
- ums-lib: Enhanced module-validator with v2.0 rules
- ums-lib: Rewrote module-parser and persona-parser tests
- ums-lib: Updated markdown renderer with proper enum usage
- CLI: Fixed build tests to mock loadTypeScriptPersona
- CLI: Updated module-discovery tests for new standard path
- CLI: Removed v1.0 fallbacks from type extensions
- ums-mcp: Fixed ESLint errors for type safety
  • Loading branch information
synthable committed Oct 13, 2025
commit eb758038c6664b1b0c670e9b2908b72e0e178141
85 changes: 34 additions & 51 deletions packages/copilot-instructions-cli/src/commands/build.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
ModuleRegistry,
} from 'ums-lib';
import { discoverAllModules } from '../utils/module-discovery.js';
import { loadTypeScriptPersona } from '../utils/typescript-loader.js';

// Mock dependencies
vi.mock('fs/promises', () => ({
Expand Down Expand Up @@ -73,6 +74,10 @@ vi.mock('../utils/module-discovery.js', () => ({
discoverAllModules: vi.fn(),
}));

vi.mock('../utils/typescript-loader.js', () => ({
loadTypeScriptPersona: vi.fn(),
}));

vi.mock('../utils/error-handler.js', () => ({
handleError: vi.fn(),
}));
Expand All @@ -89,79 +94,66 @@ describe('build command', () => {
const mockGenerateBuildReport = vi.mocked(generateBuildReport);
const mockResolvePersonaModules = vi.mocked(resolvePersonaModules);
const mockDiscoverAllModules = vi.mocked(discoverAllModules);
const mockLoadTypeScriptPersona = vi.mocked(loadTypeScriptPersona);
const mockWriteOutputFile = vi.mocked(writeOutputFile);
const mockReadFromStdin = vi.mocked(readFromStdin);

const mockPersona: Persona = {
name: 'Test Persona',
version: '1.0',
schemaVersion: '1.0',
schemaVersion: '2.0',
description: 'A test persona',
semantic: '',
identity: 'You are a helpful test assistant',
modules: [ // v2.0 spec-compliant
modules: [
{
groupName: 'Test Group',
group: 'Test Group',
ids: ['test/module-1', 'test/module-2'],
modules: ['test/module-1', 'test/module-2'], // v1.0 compat
},
],
moduleGroups: [ // v1.0 backwards compat
{
groupName: 'Test Group',
ids: ['test/module-1', 'test/module-2'],
modules: ['test/module-1', 'test/module-2'],
},
],
};

const mockModules: Module[] = [
{
id: 'test/module-1',
version: '1.0',
schemaVersion: '1.0',
shape: 'procedure',
capabilities: [],
meta: {
name: 'Test Module 1',
description: 'First test module',
semantic: 'Test semantic content',
},
version: '1.0.0',
schemaVersion: '2.0',
capabilities: ['testing'],
metadata: {
name: 'Test Module 1',
description: 'First test module',
semantic: 'Test semantic content',
},
body: {
goal: 'Test goal',
process: ['Step 1', 'Step 2'],
instruction: {
type: 'instruction',
instruction: {
purpose: 'Test goal',
process: ['Step 1', 'Step 2'],
},
},
} as Module,
{
id: 'test/module-2',
version: '1.0',
schemaVersion: '1.0',
shape: 'specification',
capabilities: [],
meta: {
name: 'Test Module 2',
description: 'Second test module',
semantic: 'Test semantic content',
},
version: '1.0.0',
schemaVersion: '2.0',
capabilities: ['testing'],
metadata: {
name: 'Test Module 2',
description: 'Second test module',
semantic: 'Test semantic content',
},
body: {
goal: 'Test specification',
instruction: {
type: 'instruction',
instruction: {
purpose: 'Test specification',
},
},
} as Module,
];

const mockBuildReport: BuildReport = {
personaName: 'Test Persona',
schemaVersion: '1.0',
schemaVersion: '2.0',
toolVersion: '1.0.0',
personaDigest: 'abc123',
buildTimestamp: '2023-01-01T00:00:00.000Z',
Expand All @@ -188,6 +180,7 @@ describe('build command', () => {
warnings: [],
});

mockLoadTypeScriptPersona.mockResolvedValue(mockPersona);
mockParsePersona.mockReturnValue(mockPersona);
mockRenderMarkdown.mockReturnValue(
'# Test Persona Instructions\\n\\nTest content'
Expand Down Expand Up @@ -216,7 +209,7 @@ describe('build command', () => {

// Assert
expect(mockDiscoverAllModules).toHaveBeenCalled();
expect(mockParsePersona).toHaveBeenCalled();
expect(mockLoadTypeScriptPersona).toHaveBeenCalledWith('test.persona.yml');
expect(mockRenderMarkdown).toHaveBeenCalledWith(mockPersona, mockModules);
expect(mockGenerateBuildReport).toHaveBeenCalledWith(
mockPersona,
Expand All @@ -232,24 +225,15 @@ describe('build command', () => {
);
});

it('should build persona from stdin with output to stdout', async () => {
it('should build persona from file with output to stdout', async () => {
// Arrange
const options = {
persona: 'test.persona.yml',
verbose: false,
// No output specified - should write to stdout
};

const mockStdinContent = `
name: Test Persona
description: A test persona
semantic: ""
identity: You are a helpful test assistant
moduleGroups:
- groupName: Test Group
modules:
- test/module-1
`;

mockReadFromStdin.mockResolvedValue(mockStdinContent);
mockReadFromStdin.mockResolvedValue('');
const mockConsoleLog = vi
.spyOn(console, 'log')
.mockImplementation(() => {});
Expand All @@ -258,8 +242,7 @@ moduleGroups:
await handleBuild(options);

// Assert
expect(mockReadFromStdin).toHaveBeenCalled();
expect(mockParsePersona).toHaveBeenCalledWith(mockStdinContent);
expect(mockLoadTypeScriptPersona).toHaveBeenCalledWith('test.persona.yml');
expect(mockConsoleLog).toHaveBeenCalledWith(
'# Test Persona Instructions\\n\\nTest content'
);
Expand Down
140 changes: 28 additions & 112 deletions packages/copilot-instructions-cli/src/commands/build.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,31 @@
/**
* @module commands/ums-build
* @description UMS build command implementation
* Supports both v1.0 (YAML) and v2.0 (TypeScript) formats
* Supports UMS v2.0 (TypeScript) format only
*/

import chalk from 'chalk';
import { handleError } from '../utils/error-handler.js';
import {
parsePersona,
renderMarkdown,
generateBuildReport,
ConflictError,
type UMSPersona,
type UMSModule,
type Persona,
type Module,
type BuildReport,
type ModuleRegistry,
} from 'ums-lib';
import { createBuildProgress } from '../utils/progress.js';
import { writeOutputFile, readFromStdin } from '../utils/file-operations.js';
import { writeOutputFile } from '../utils/file-operations.js';
import { discoverAllModules } from '../utils/module-discovery.js';
import {
loadTypeScriptPersona,
detectUMSVersion,
} from '../utils/typescript-loader.js';
import { loadTypeScriptPersona } from '../utils/typescript-loader.js';

/**
* Options for the build command
*/
export interface BuildOptions {
/** Path to persona file, or undefined for stdin */
persona?: string;
/** Path to persona .ts file */
persona: string;
Comment on lines +27 to +28
Copy link

Copilot AI Oct 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The breaking change from optional to required persona parameter should be documented in migration notes, as this affects existing CLI usage patterns.

Copilot uses AI. Check for mistakes.
/** Output file path, or undefined for stdout */
output?: string;
/** Enable verbose output */
Expand Down Expand Up @@ -65,19 +61,15 @@ export async function handleBuild(options: BuildOptions): Promise<void> {
)
);

// Count module groups (supporting both v1.0 and v2.0 formats)
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
const moduleGroups = result.persona.modules?.filter(
// Count module groups (v2.0 format)
const moduleGroups = result.persona.modules.filter(
entry => typeof entry !== 'string'
);
// eslint-disable-next-line @typescript-eslint/no-deprecated, @typescript-eslint/no-unnecessary-condition
const groupCount = moduleGroups?.length ?? result.persona.moduleGroups?.length ?? 0;
const groupCount = moduleGroups.length;

if (groupCount > 1) {
console.log(
chalk.gray(
`[INFO] build: Organized into ${groupCount} module groups`
)
chalk.gray(`[INFO] build: Organized into ${groupCount} module groups`)
);
}
}
Expand All @@ -98,8 +90,7 @@ export async function handleBuild(options: BuildOptions): Promise<void> {
*/
interface BuildEnvironment {
registry: ModuleRegistry;
// eslint-disable-next-line @typescript-eslint/no-deprecated
persona: UMSPersona;
persona: Persona;
outputPath?: string | undefined;
warnings: string[];
}
Expand Down Expand Up @@ -141,69 +132,16 @@ async function setupBuildEnvironment(
}
}

// Load persona
// Load persona (v2.0 TypeScript format only)
progress.update('Loading persona...');
// eslint-disable-next-line @typescript-eslint/no-deprecated
let persona: UMSPersona;

if (personaPath) {
// Detect format and load accordingly
const version = detectUMSVersion(personaPath);
progress.update(
`Reading persona file (UMS v${version}): ${personaPath}`
);

if (version === '2.0') {
// v2.0 TypeScript format
persona = (await loadTypeScriptPersona(personaPath));
if (verbose) {
console.log(
chalk.gray(
`[INFO] build: Loaded TypeScript persona from ${personaPath}`
)
);
}
} else {
// v1.0 YAML format
const { readFile } = await import('fs/promises');
const personaContent = await readFile(personaPath, 'utf-8');
persona = parsePersona(personaContent);
if (verbose) {
console.log(
chalk.gray(`[INFO] build: Loaded YAML persona from ${personaPath}`)
);
}
}
} else {
// stdin is always treated as v1.0 YAML
progress.update('Reading persona from stdin...');

if (process.stdin.isTTY) {
progress.fail('No persona file specified and stdin is not available');
throw new Error(
'No persona file specified and stdin is not available. ' +
'Use --persona <file> to specify a persona file or pipe YAML content to stdin.'
);
}

const personaContent = await readFromStdin();

if (!personaContent.trim()) {
progress.fail('No persona content provided via stdin');
throw new Error(
'No persona content received from stdin. ' +
'Ensure YAML content is piped to stdin or use --persona <file>.'
);
}
progress.update(`Reading persona file: ${personaPath}`);

persona = parsePersona(personaContent);

if (verbose) {
console.log(chalk.gray('[INFO] build: Reading persona from stdin'));
}
}
const persona = await loadTypeScriptPersona(personaPath);

if (verbose) {
console.log(
chalk.gray(`[INFO] build: Loaded TypeScript persona from ${personaPath}`)
);
console.log(chalk.gray(`[INFO] build: Loaded persona '${persona.name}'`));
}

Expand All @@ -222,57 +160,35 @@ async function setupBuildEnvironment(
*/
interface BuildResult {
markdown: string;
// eslint-disable-next-line @typescript-eslint/no-deprecated
modules: UMSModule[];
// eslint-disable-next-line @typescript-eslint/no-deprecated
persona: UMSPersona;
modules: Module[];
persona: Persona;
warnings: string[];
buildReport: BuildReport;
}

/**
* Processes persona and modules to generate build result
* Handles both v1.0 (moduleGroups) and v2.0 (modules) formats
* Handles v2.0 (modules) format only
*/
function processPersonaAndModules(
environment: BuildEnvironment,
progress: ReturnType<typeof createBuildProgress>
): BuildResult {
progress.update('Resolving modules from registry...');

// Extract module IDs from persona (supporting both v1.0 and v2.0 formats)
let requiredModuleIds: string[];

// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (environment.persona.modules?.length) {
// v2.0 format: modules array with ModuleEntry union type
requiredModuleIds = environment.persona.modules.flatMap(entry => {
// Extract module IDs from persona (v2.0 format only)
const requiredModuleIds: string[] = environment.persona.modules.flatMap(
entry => {
if (typeof entry === 'string') {
return [entry];
} else {
// ModuleGroup: extract IDs from group
// eslint-disable-next-line @typescript-eslint/no-deprecated, @typescript-eslint/no-unnecessary-condition
return entry.ids ?? entry.modules ?? [];
return entry.ids;
}
});
} else if (
// eslint-disable-next-line @typescript-eslint/no-deprecated
environment.persona.moduleGroups?.length
) {
// v1.0 format: moduleGroups array
// eslint-disable-next-line @typescript-eslint/no-deprecated
requiredModuleIds = environment.persona.moduleGroups.flatMap(
// eslint-disable-next-line @typescript-eslint/no-deprecated, @typescript-eslint/no-unnecessary-condition
group => group.modules ?? group.ids ?? []
);
} else {
throw new Error(
'Persona has no modules. Either "modules" (v2.0) or "moduleGroups" (v1.0) must be specified.'
);
}
}
);

// eslint-disable-next-line @typescript-eslint/no-deprecated
const resolvedModules: UMSModule[] = [];
const resolvedModules: Module[] = [];
const resolutionWarnings: string[] = [];
const missingModules: string[] = [];

Expand Down
Loading