Compare commits
No commits in common. "1d49e045dba5c71cf12dfcd3672f2471566e066f" and "d83ce5e21fa82fc4a4e5d9b224fb7693e080885b" have entirely different histories.
1d49e045db
...
d83ce5e21f
|
|
@ -42,15 +42,19 @@ Load `{moduleYamlConventionsFile}` for reference.
|
||||||
|
|
||||||
Create `{targetLocation}/module.yaml` with:
|
Create `{targetLocation}/module.yaml` with:
|
||||||
|
|
||||||
**Required fields (replace with actual values):**
|
**⚠️ CRITICAL: All required fields MUST be populated with actual values, not placeholders:**
|
||||||
|
|
||||||
|
**Required fields:**
|
||||||
```yaml
|
```yaml
|
||||||
code: "my-module" # kebab-case, 2-20 chars, starts with letter
|
code: {module_code} # ⚠️ REQUIRED: Must be the actual kebab-case module code (e.g., "my-module")
|
||||||
name: "My Module: Description" # human-readable name
|
name: "{module_display_name}" # ⚠️ REQUIRED: Human-readable name (e.g., "My Module: Description")
|
||||||
header: "One-line summary" # one-line summary
|
header: "{brief_header}" # ⚠️ REQUIRED: One-line summary
|
||||||
subheader: "Additional context" # additional context
|
subheader: "{additional_context}" # ⚠️ REQUIRED: Additional context
|
||||||
default_selected: false # typically false for new modules
|
default_selected: false # ⚠️ REQUIRED: Boolean, typically false for new modules
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Validation:** The module will fail installation if `code` is missing or contains placeholder text like `{module_code}`.
|
||||||
|
|
||||||
**Note for Extension modules:** `code:` matches base module
|
**Note for Extension modules:** `code:` matches base module
|
||||||
|
|
||||||
### 3. Add Custom Variables
|
### 3. Add Custom Variables
|
||||||
|
|
|
||||||
|
|
@ -38,12 +38,13 @@ Read `{targetPath}/module.yaml`
|
||||||
|
|
||||||
### 2. Validate Required Fields
|
### 2. Validate Required Fields
|
||||||
|
|
||||||
Check required fields (must have actual values, not placeholders):
|
**⚠️ CRITICAL:** Check for required frontmatter (these MUST be actual values, not placeholders):
|
||||||
- [ ] `code:` present (kebab-case, 2-20 chars, starts with letter)
|
- [ ] `code:` present and valid (kebab-case, 2-20 chars, starts with letter, e.g., `my-module`)
|
||||||
|
- ❌ FAIL if missing, empty, or contains placeholder text like `{module_code}`
|
||||||
- [ ] `name:` present (non-empty string)
|
- [ ] `name:` present (non-empty string)
|
||||||
- [ ] `header:` present (non-empty string)
|
- [ ] `header:` present (non-empty string)
|
||||||
- [ ] `subheader:` present (non-empty string)
|
- [ ] `subheader:` present (non-empty string)
|
||||||
- [ ] `default_selected:` present (boolean)
|
- [ ] `default_selected:` present (boolean - typically `false` for new modules)
|
||||||
|
|
||||||
### 3. Validate Custom Variables
|
### 3. Validate Custom Variables
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
# Test: Valid module with default_selected set to true
|
|
||||||
# Expected: PASS
|
|
||||||
|
|
||||||
code: core-like
|
|
||||||
name: Core Module
|
|
||||||
header: Core Header
|
|
||||||
subheader: Module with default_selected true
|
|
||||||
default_selected: true
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
# Test: Valid module without default_selected (optional for core module)
|
||||||
|
# Expected: PASS
|
||||||
|
|
||||||
|
code: core-like
|
||||||
|
name: Core Module
|
||||||
|
header: Core Header
|
||||||
|
subheader: Core modules don't need default_selected
|
||||||
|
|
||||||
|
|
@ -163,10 +163,10 @@ function validateError(error, expectation) {
|
||||||
* @returns {{passed: boolean, message: string}}
|
* @returns {{passed: boolean, message: string}}
|
||||||
*/
|
*/
|
||||||
function runTest(filePath) {
|
function runTest(filePath) {
|
||||||
try {
|
const metadata = parseTestMetadata(filePath);
|
||||||
const metadata = parseTestMetadata(filePath);
|
const { shouldPass, errorExpectation } = metadata;
|
||||||
const { shouldPass, errorExpectation } = metadata;
|
|
||||||
|
|
||||||
|
try {
|
||||||
const fileContent = fs.readFileSync(filePath, 'utf8');
|
const fileContent = fs.readFileSync(filePath, 'utf8');
|
||||||
let moduleData;
|
let moduleData;
|
||||||
|
|
||||||
|
|
@ -195,13 +195,7 @@ function runTest(filePath) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!result.success && !shouldPass) {
|
if (!result.success && !shouldPass) {
|
||||||
const actualError = result.error?.issues?.[0];
|
const actualError = result.error.issues[0];
|
||||||
if (!actualError) {
|
|
||||||
return {
|
|
||||||
passed: false,
|
|
||||||
message: 'Expected validation error issues, but validator returned none',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (errorExpectation) {
|
if (errorExpectation) {
|
||||||
const validation = validateError(actualError, errorExpectation);
|
const validation = validateError(actualError, errorExpectation);
|
||||||
|
|
@ -221,7 +215,7 @@ function runTest(filePath) {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
passed: true,
|
passed: true,
|
||||||
message: `Got expected validation error: ${actualError.message}`,
|
message: `Got expected validation error: ${actualError?.message}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -235,7 +229,7 @@ function runTest(filePath) {
|
||||||
if (!result.success && shouldPass) {
|
if (!result.success && shouldPass) {
|
||||||
return {
|
return {
|
||||||
passed: false,
|
passed: false,
|
||||||
message: `Expected validation to PASS but it FAILED: ${result.error?.issues?.[0]?.message ?? 'Unknown error'}`,
|
message: `Expected validation to PASS but it FAILED: ${result.error.issues[0]?.message}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -266,7 +260,7 @@ async function main() {
|
||||||
|
|
||||||
if (testFiles.length === 0) {
|
if (testFiles.length === 0) {
|
||||||
console.log(`${colors.yellow}⚠️ No test fixtures found${colors.reset}`);
|
console.log(`${colors.yellow}⚠️ No test fixtures found${colors.reset}`);
|
||||||
process.exit(1);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Found ${colors.cyan}${testFiles.length}${colors.reset} test fixture(s)\n`);
|
console.log(`Found ${colors.cyan}${testFiles.length}${colors.reset} test fixture(s)\n`);
|
||||||
|
|
|
||||||
|
|
@ -9,13 +9,12 @@ const MODULE_CODE_PATTERN = /^[a-z][a-z0-9-]{1,19}$/;
|
||||||
/**
|
/**
|
||||||
* Validate a module YAML payload against the schema.
|
* Validate a module YAML payload against the schema.
|
||||||
*
|
*
|
||||||
* @param {string} filePath Path to the module file (used to detect core vs non-core modules).
|
* @param {string} filePath Path to the module file (for consistency with other validators).
|
||||||
* @param {unknown} moduleYaml Parsed YAML content.
|
* @param {unknown} moduleYaml Parsed YAML content.
|
||||||
* @returns {import('zod').SafeParseReturnType<unknown, unknown>} SafeParse result.
|
* @returns {import('zod').SafeParseReturnType<unknown, unknown>} SafeParse result.
|
||||||
*/
|
*/
|
||||||
function validateModuleFile(filePath, moduleYaml) {
|
function validateModuleFile(filePath, moduleYaml) {
|
||||||
const isCoreModule = typeof filePath === 'string' && filePath.replaceAll('\\', '/').includes('src/core/');
|
const schema = moduleSchema();
|
||||||
const schema = moduleSchema({ isCoreModule });
|
|
||||||
return schema.safeParse(moduleYaml);
|
return schema.safeParse(moduleYaml);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -25,11 +24,9 @@ module.exports = { validateModuleFile };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the Zod schema for validating a module.yaml file.
|
* Build the Zod schema for validating a module.yaml file.
|
||||||
* @param {{isCoreModule?: boolean}} options - Options for schema validation.
|
|
||||||
* @returns {import('zod').ZodSchema} Configured Zod schema instance.
|
* @returns {import('zod').ZodSchema} Configured Zod schema instance.
|
||||||
*/
|
*/
|
||||||
function moduleSchema(options) {
|
function moduleSchema() {
|
||||||
const { isCoreModule = false } = options ?? {};
|
|
||||||
return z
|
return z
|
||||||
.object({
|
.object({
|
||||||
// Required fields
|
// Required fields
|
||||||
|
|
@ -39,24 +36,17 @@ function moduleSchema(options) {
|
||||||
name: createNonEmptyString('module.name'),
|
name: createNonEmptyString('module.name'),
|
||||||
header: createNonEmptyString('module.header'),
|
header: createNonEmptyString('module.header'),
|
||||||
subheader: createNonEmptyString('module.subheader'),
|
subheader: createNonEmptyString('module.subheader'),
|
||||||
// default_selected is optional for core module, required for non-core modules
|
// default_selected is optional for core module, required for others
|
||||||
|
// Core module doesn't need this as it's always included
|
||||||
default_selected: z.boolean().optional(),
|
default_selected: z.boolean().optional(),
|
||||||
|
|
||||||
// Optional fields
|
// Optional fields
|
||||||
type: createNonEmptyString('module.type').optional(),
|
type: createNonEmptyString('module.type').optional(),
|
||||||
global: z.boolean().optional(),
|
global: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
|
.strict()
|
||||||
.passthrough()
|
.passthrough()
|
||||||
.superRefine((value, ctx) => {
|
.superRefine((value, ctx) => {
|
||||||
// Enforce default_selected for non-core modules
|
|
||||||
if (!isCoreModule && !('default_selected' in value)) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: 'custom',
|
|
||||||
path: ['default_selected'],
|
|
||||||
message: 'module.default_selected is required for non-core modules',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate any additional keys as variable definitions
|
// Validate any additional keys as variable definitions
|
||||||
const reservedKeys = new Set(['code', 'name', 'header', 'subheader', 'default_selected', 'type', 'global']);
|
const reservedKeys = new Set(['code', 'name', 'header', 'subheader', 'default_selected', 'type', 'global']);
|
||||||
|
|
||||||
|
|
@ -67,7 +57,7 @@ function moduleSchema(options) {
|
||||||
|
|
||||||
const variableValue = value[key];
|
const variableValue = value[key];
|
||||||
|
|
||||||
// Skip if null/undefined
|
// Skip if it's a comment (starts with #) or null/undefined
|
||||||
if (variableValue === null || variableValue === undefined) {
|
if (variableValue === null || variableValue === undefined) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -97,16 +87,8 @@ function validateVariableDefinition(variableName, variableValue) {
|
||||||
return { valid: false, error: `${variableName} must be an object with variable definition properties` };
|
return { valid: false, error: `${variableName} must be an object with variable definition properties` };
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasInherit = 'inherit' in variableValue;
|
|
||||||
const hasPrompt = 'prompt' in variableValue;
|
|
||||||
|
|
||||||
// Enforce mutual exclusivity: inherit and prompt cannot coexist
|
|
||||||
if (hasInherit && hasPrompt) {
|
|
||||||
return { valid: false, error: `${variableName} must not define both 'inherit' and 'prompt'` };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for inherit alias - if present, it's the only required field
|
// Check for inherit alias - if present, it's the only required field
|
||||||
if (hasInherit) {
|
if ('inherit' in variableValue) {
|
||||||
if (typeof variableValue.inherit !== 'string' || variableValue.inherit.trim().length === 0) {
|
if (typeof variableValue.inherit !== 'string' || variableValue.inherit.trim().length === 0) {
|
||||||
return { valid: false, error: `${variableName}.inherit must be a non-empty string` };
|
return { valid: false, error: `${variableName}.inherit must be a non-empty string` };
|
||||||
}
|
}
|
||||||
|
|
@ -114,7 +96,7 @@ function validateVariableDefinition(variableName, variableValue) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise, prompt is required
|
// Otherwise, prompt is required
|
||||||
if (!hasPrompt) {
|
if (!('prompt' in variableValue)) {
|
||||||
return { valid: false, error: `${variableName} must have a 'prompt' or 'inherit' field` };
|
return { valid: false, error: `${variableName} must have a 'prompt' or 'inherit' field` };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -137,15 +119,8 @@ function validateVariableDefinition(variableName, variableValue) {
|
||||||
return { valid: false, error: `${variableName}.prompt must be a string or array of strings` };
|
return { valid: false, error: `${variableName}.prompt must be a string or array of strings` };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enforce mutual exclusivity: single-select and multi-select cannot coexist
|
|
||||||
const hasSingle = 'single-select' in variableValue;
|
|
||||||
const hasMulti = 'multi-select' in variableValue;
|
|
||||||
if (hasSingle && hasMulti) {
|
|
||||||
return { valid: false, error: `${variableName} must not define both 'single-select' and 'multi-select'` };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate optional single-select
|
// Validate optional single-select
|
||||||
if (hasSingle) {
|
if ('single-select' in variableValue) {
|
||||||
const selectResult = validateSelectOptions(variableName, 'single-select', variableValue['single-select']);
|
const selectResult = validateSelectOptions(variableName, 'single-select', variableValue['single-select']);
|
||||||
if (!selectResult.valid) {
|
if (!selectResult.valid) {
|
||||||
return selectResult;
|
return selectResult;
|
||||||
|
|
@ -153,7 +128,7 @@ function validateVariableDefinition(variableName, variableValue) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate optional multi-select
|
// Validate optional multi-select
|
||||||
if (hasMulti) {
|
if ('multi-select' in variableValue) {
|
||||||
const selectResult = validateSelectOptions(variableName, 'multi-select', variableValue['multi-select']);
|
const selectResult = validateSelectOptions(variableName, 'multi-select', variableValue['multi-select']);
|
||||||
if (!selectResult.valid) {
|
if (!selectResult.valid) {
|
||||||
return selectResult;
|
return selectResult;
|
||||||
|
|
|
||||||
|
|
@ -45,14 +45,14 @@ async function main(customProjectRoot) {
|
||||||
|
|
||||||
// Validate each file
|
// Validate each file
|
||||||
for (const filePath of moduleFiles) {
|
for (const filePath of moduleFiles) {
|
||||||
const relativePath = path.relative(project_root, filePath).replaceAll('\\', '/');
|
const relativePath = path.relative(process.cwd(), filePath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const fileContent = fs.readFileSync(filePath, 'utf8');
|
const fileContent = fs.readFileSync(filePath, 'utf8');
|
||||||
const moduleData = yaml.parse(fileContent);
|
const moduleData = yaml.parse(fileContent);
|
||||||
|
|
||||||
// Ensure path starts with src/ for core module detection
|
// Convert absolute path to relative src/ path for context
|
||||||
const srcRelativePath = relativePath.startsWith('src/') ? relativePath : `src/${relativePath}`;
|
const srcRelativePath = relativePath.startsWith('src/') ? relativePath : path.relative(project_root, filePath).replaceAll('\\', '/');
|
||||||
|
|
||||||
const result = validateModuleFile(srcRelativePath, moduleData);
|
const result = validateModuleFile(srcRelativePath, moduleData);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue