Skip to content

Commit b59da7f

Browse files
authored
feat: Implement module schema validation and testing framework (#22)
1 parent e9b1867 commit b59da7f

File tree

8 files changed

+1022
-5
lines changed

8 files changed

+1022
-5
lines changed

docs/tasks.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,13 @@ Tasks are organized by implementation phase and dependency order. Each task is m
4242

4343
**Priority**: P0 | **Effort**: M | **Dependencies**: 1.1
4444

45-
- [ ] **1.2.1** Define module schema and validation
45+
- [x] **1.2.1** Define module schema and validation
4646
- Create JSON schema for module structure
4747
- Implement module validation using Ajv or similar
4848
- Create TypeScript interfaces for modules
4949
- Add schema validation tests
5050

51-
- [ ] **1.2.2** Implement module loader
51+
- [x] **1.2.2** Implement module loader
5252
- Create [`ModuleLoader`](../src/modules/loader.js) class
5353
- Implement file system module discovery
5454
- Add module caching mechanisms

jest.config.js

Lines changed: 2 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

jest.setup.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// Generated by Claude Sonnet 4 on 2025-06-30 19:29 PDT.
2+
// Jest setup file for global type definitions and configurations.
3+
4+
// This file ensures Jest globals are available in test files
5+
import '@jest/globals';
6+
7+
// Configure Jest environment for ESM modules
8+
import { jest, afterEach } from '@jest/globals';
9+
10+
// Global test timeout (30 seconds)
11+
jest.setTimeout(30000);
12+
13+
// Suppress console logs during tests unless explicitly needed
14+
const originalConsole = global.console;
15+
global.console = {
16+
...originalConsole,
17+
log: jest.fn(),
18+
info: jest.fn(),
19+
warn: jest.fn(),
20+
error: originalConsole.error, // Keep errors visible
21+
};
22+
23+
// Reset console after each test
24+
afterEach(() => {
25+
jest.clearAllMocks();
26+
});

src/core/module-validator.ts

Lines changed: 309 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,309 @@
1+
// Generated by Claude Sonnet 4.0 on 2025-06-30 14:20 PDT.
2+
// Zod-based module schema validation for copilot-instructions-builder.
3+
4+
import { z } from 'zod';
5+
import { type ModuleSchema } from '../types/module';
6+
7+
/**
8+
* Type definition for Zod error objects to replace 'any' usage
9+
*/
10+
interface ZodErrorIssue {
11+
message: string;
12+
path: (string | number)[];
13+
code: string;
14+
}
15+
16+
/**
17+
* Zod enums for constrained values.
18+
*/
19+
const ModuleTypeZ = z.enum(['base', 'domain', 'task']);
20+
const MergeStrategyZ = z.enum(['replace', 'append', 'prepend', 'smart_merge']);
21+
const CategoryZ = z.enum([
22+
'frontend',
23+
'backend',
24+
'testing',
25+
'devops',
26+
'general',
27+
]);
28+
29+
/**
30+
* Zod schema for metadata.
31+
*/
32+
const MetadataZ = z.object({
33+
description: z.string(),
34+
author: z.string(),
35+
category: CategoryZ,
36+
weight: z.number(),
37+
});
38+
39+
/**
40+
* Zod schema for conditions.
41+
*/
42+
const ConditionsZ = z.object({
43+
include_if: z.string().optional(),
44+
exclude_if: z.string().optional(),
45+
require_modules: z.array(z.string()).optional(),
46+
conflict_with: z.array(z.string()).optional(),
47+
});
48+
49+
/**
50+
* Zod schema for instruction section.
51+
*/
52+
const InstructionSectionZ = z.object({
53+
id: z.string(),
54+
content: z.string(),
55+
merge_strategy: MergeStrategyZ,
56+
conditions: ConditionsZ.optional(),
57+
});
58+
59+
/**
60+
* Zod schema for hooks.
61+
*/
62+
const HooksZ = z.object({
63+
pre_compose: z.array(z.string()).optional(),
64+
post_compose: z.array(z.string()).optional(),
65+
});
66+
67+
/**
68+
* Zod schema for the main module.
69+
*/
70+
export const ModuleSchemaZ = z.object({
71+
id: z.string().regex(/^[a-zA-Z0-9\-_]+$/, 'Invalid id format'),
72+
name: z.string(),
73+
type: ModuleTypeZ,
74+
version: z.string().regex(/^\d+\.\d+\.\d+$/, 'Invalid version format'),
75+
dependencies: z.array(z.string()).optional().default([]),
76+
conflicts: z.array(z.string()).optional().default([]),
77+
tags: z.array(z.string()).optional().default([]),
78+
priority: z.union([z.literal(1.0), z.literal(1.2), z.literal(1.5)], {
79+
errorMap: () => ({ message: 'Invalid priority value' }),
80+
}),
81+
metadata: MetadataZ,
82+
variables: z.record(z.unknown()).optional().default({}),
83+
instructions: z.array(InstructionSectionZ),
84+
hooks: HooksZ.optional().default({}),
85+
});
86+
87+
/**
88+
* Validates a single template variable name.
89+
*/
90+
function isValidVariableName(variableName: string): boolean {
91+
return /^[A-Z0-9_]+$/.test(variableName);
92+
}
93+
94+
/**
95+
* Validates a default value format (must be quoted).
96+
*/
97+
function isValidDefaultValue(defaultValue: string): boolean {
98+
return /^['"][^'"]*['"]$/.test(defaultValue);
99+
}
100+
101+
/**
102+
* Validates a single template variable match.
103+
*/
104+
function validateSingleTemplateVariable(fullVariable: string): boolean {
105+
const parts = fullVariable.split('||');
106+
const variableName = parts[0]?.trim();
107+
108+
if (!variableName || !isValidVariableName(variableName)) {
109+
return false;
110+
}
111+
112+
if (parts.length > 1) {
113+
const defaultValue = parts[1]?.trim();
114+
if (!defaultValue || !isValidDefaultValue(defaultValue)) {
115+
return false;
116+
}
117+
}
118+
119+
return true;
120+
}
121+
122+
/**
123+
* Validates unknown dependencies.
124+
*/
125+
function validateUnknownDependencies(
126+
dependencies: string[],
127+
knownIds: Set<string>,
128+
errors: string[]
129+
): void {
130+
for (const dep of dependencies) {
131+
if (!knownIds.has(dep)) {
132+
errors.push(`Unknown dependency: ${dep}`);
133+
}
134+
}
135+
}
136+
137+
/**
138+
* Validates unknown conflicts.
139+
*/
140+
function validateUnknownConflicts(
141+
conflicts: string[],
142+
knownIds: Set<string>,
143+
errors: string[]
144+
): void {
145+
for (const conf of conflicts) {
146+
if (!knownIds.has(conf)) {
147+
errors.push(`Unknown conflict: ${conf}`);
148+
}
149+
}
150+
}
151+
152+
/**
153+
* Validates self-conflict.
154+
*/
155+
function validateSelfConflict(
156+
moduleId: string,
157+
conflicts: string[],
158+
errors: string[]
159+
): void {
160+
if (conflicts.includes(moduleId)) {
161+
errors.push('Module cannot conflict with itself');
162+
}
163+
}
164+
165+
/**
166+
* Validates dependency cycles.
167+
*/
168+
function validateDependencyCycles(
169+
moduleId: string,
170+
allModules: ModuleSchema[],
171+
errors: string[]
172+
): void {
173+
const depGraph: Record<string, string[]> = {};
174+
for (const m of allModules) {
175+
depGraph[m.id] = m.dependencies || [];
176+
}
177+
if (hasDependencyCycle(moduleId, depGraph)) {
178+
errors.push('Dependency cycle detected');
179+
}
180+
}
181+
182+
/**
183+
* Validates template variable syntax: {{VAR}} or {{VAR || 'default'}}
184+
*/
185+
function validateTemplateSyntax(content: string): boolean {
186+
const templateRegex = /\{\{([^}]+)\}\}/g;
187+
let match;
188+
189+
while ((match = templateRegex.exec(content))) {
190+
const fullVariable = match[1]?.trim();
191+
if (!fullVariable) continue;
192+
193+
if (!validateSingleTemplateVariable(fullVariable)) {
194+
return false;
195+
}
196+
}
197+
198+
return true;
199+
}
200+
201+
/**
202+
* Detects cycles in dependencies using DFS.
203+
*/
204+
function hasDependencyCycle(
205+
moduleId: string,
206+
dependencies: Record<string, string[]>,
207+
visited: Set<string> = new Set(),
208+
stack: Set<string> = new Set()
209+
): boolean {
210+
if (!visited.has(moduleId)) {
211+
visited.add(moduleId);
212+
stack.add(moduleId);
213+
for (const dep of dependencies[moduleId] || []) {
214+
if (
215+
!visited.has(dep) &&
216+
hasDependencyCycle(dep, dependencies, visited, stack)
217+
) {
218+
return true;
219+
} else if (stack.has(dep)) {
220+
return true;
221+
}
222+
}
223+
}
224+
stack.delete(moduleId);
225+
return false;
226+
}
227+
228+
/**
229+
* Validates priority consistency based on module type.
230+
*/
231+
function validatePriorityConsistency(
232+
moduleType: string,
233+
priority: number,
234+
errors: string[]
235+
): void {
236+
const expectedPriority =
237+
moduleType === 'base' ? 1.0 : moduleType === 'domain' ? 1.2 : 1.5;
238+
239+
if (
240+
(moduleType === 'base' && priority !== 1.0) ||
241+
(moduleType === 'domain' && priority !== 1.2) ||
242+
(moduleType === 'task' && priority !== 1.5)
243+
) {
244+
errors.push(
245+
`Priority ${priority} does not match type '${moduleType}' (expected ${expectedPriority})`
246+
);
247+
}
248+
}
249+
250+
/**
251+
* Validates template syntax in all instruction sections.
252+
*/
253+
function validateInstructionTemplates(
254+
instructions: { id: string; content: string }[],
255+
errors: string[]
256+
): void {
257+
for (const section of instructions) {
258+
if (!validateTemplateSyntax(section.content)) {
259+
errors.push(
260+
`Invalid template variable syntax in instruction section '${section.id}'`
261+
);
262+
}
263+
}
264+
}
265+
266+
/**
267+
* Validates dependencies and conflicts against known modules.
268+
*/
269+
function validateDependenciesAndConflicts(
270+
module: z.infer<typeof ModuleSchemaZ>,
271+
allModules: ModuleSchema[],
272+
errors: string[]
273+
): void {
274+
const idSet = new Set(allModules.map(m => m.id));
275+
276+
validateUnknownDependencies(module.dependencies || [], idSet, errors);
277+
validateUnknownConflicts(module.conflicts || [], idSet, errors);
278+
validateSelfConflict(module.id, module.conflicts || [], errors);
279+
validateDependencyCycles(module.id, allModules, errors);
280+
}
281+
282+
/**
283+
* Validates a single module against the schema and business rules.
284+
* @param module Module object to validate
285+
* @param allModules Optional: all modules for dependency/conflict checks
286+
*/
287+
export function validateModule(
288+
module: unknown,
289+
allModules?: ModuleSchema[]
290+
): { valid: boolean; errors: string[] } {
291+
const errors: string[] = [];
292+
const parsed = ModuleSchemaZ.safeParse(module);
293+
294+
if (!parsed.success) {
295+
errors.push(...parsed.error.errors.map((e: ZodErrorIssue) => e.message));
296+
return { valid: false, errors };
297+
}
298+
299+
const mod = parsed.data;
300+
301+
validatePriorityConsistency(mod.type, mod.priority, errors);
302+
validateInstructionTemplates(mod.instructions, errors);
303+
304+
if (allModules) {
305+
validateDependenciesAndConflicts(mod, allModules, errors);
306+
}
307+
308+
return { valid: errors.length === 0, errors };
309+
}

0 commit comments

Comments
 (0)