Skip to content

Commit 95facaa

Browse files
authored
chore: add front matter checker (#36)
1 parent bcd18fb commit 95facaa

File tree

2 files changed

+328
-0
lines changed

2 files changed

+328
-0
lines changed
Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
#!/usr/bin/env node
2+
'use strict';
3+
4+
/**
5+
* Validates front matter in AI cookbook recipe README files.
6+
*
7+
* Top-level recipe READMEs (e.g., agents/recipe-name/README.md) must have valid front matter.
8+
* Nested READMEs within a recipe (e.g., agents/recipe-name/tools/README.md) are exempt.
9+
*/
10+
11+
const fs = require('fs/promises');
12+
const path = require('path');
13+
14+
// YAML parsing with built-in support - using a simple parser to avoid external dependencies
15+
function parseSimpleYaml(text) {
16+
const lines = text.split('\n');
17+
const result = {};
18+
19+
for (let i = 0; i < lines.length; i++) {
20+
const line = lines[i].trimEnd();
21+
22+
// Skip empty lines and comments
23+
if (!line || line.startsWith('#')) continue;
24+
25+
// Match key-value pairs
26+
const match = line.match(/^([\w-]+):\s*(.*)$/);
27+
if (!match) continue;
28+
29+
const [, key, value] = match;
30+
31+
// Handle arrays (lines starting with -)
32+
if (value === '') {
33+
const arrayValues = [];
34+
i++;
35+
while (i < lines.length) {
36+
const arrayLine = lines[i].trim();
37+
if (!arrayLine.startsWith('-')) {
38+
i--;
39+
break;
40+
}
41+
arrayValues.push(arrayLine.slice(1).trim());
42+
i++;
43+
}
44+
result[key] = arrayValues;
45+
} else if (value.startsWith('[') && value.endsWith(']')) {
46+
// Inline array
47+
result[key] = value.slice(1, -1).split(',').map(v => v.trim());
48+
} else {
49+
// Simple value
50+
result[key] = value;
51+
}
52+
}
53+
54+
return result;
55+
}
56+
57+
// Get the cookbook directory (current directory or specified path)
58+
const COOKBOOK_DIR = process.argv[2] || process.cwd();
59+
60+
/**
61+
* Extract and validate front matter from a README file
62+
* @param {string} content - The file content
63+
* @returns {{ valid: boolean, error?: string, data?: object }}
64+
*/
65+
function validateFrontMatter(content) {
66+
// Check for HTML comment front matter (<!-- ... -->)
67+
const commentPattern = /^\s*<!--([\s\S]*?)-->/;
68+
const match = commentPattern.exec(content);
69+
70+
if (!match) {
71+
return { valid: false, error: 'missing front matter comment' };
72+
}
73+
74+
const commentBody = match[1].replace(/\r/g, '').trim();
75+
76+
// Try to parse as YAML
77+
let data;
78+
try {
79+
// Normalize multi-line values (same logic as sync script)
80+
const normalizedLines = [];
81+
for (const rawLine of commentBody.split('\n')) {
82+
const line = rawLine.trimEnd();
83+
const isKeyLine = /^[ \t]*[\w-]+:/.test(line);
84+
if (isKeyLine || line.trim().length === 0 || normalizedLines.length === 0) {
85+
normalizedLines.push(line);
86+
} else {
87+
normalizedLines[normalizedLines.length - 1] = `${normalizedLines[normalizedLines.length - 1]} ${line.trim()}`;
88+
}
89+
}
90+
const normalizedComment = normalizedLines
91+
.join('\n')
92+
.replace(/(^|\n)([ \t]*[\w-]+):(?=\S)/g, (full, prefix, key) => `${prefix}${key}: `);
93+
94+
data = parseSimpleYaml(normalizedComment);
95+
} catch (error) {
96+
return { valid: false, error: `invalid YAML in front matter: ${error.message}` };
97+
}
98+
99+
if (data === null || typeof data !== 'object' || Array.isArray(data)) {
100+
return { valid: false, error: 'front matter must be a YAML object' };
101+
}
102+
103+
// Check for required fields
104+
const requiredFields = ['description'];
105+
const missingFields = requiredFields.filter(field => !data[field]);
106+
107+
if (missingFields.length > 0) {
108+
return { valid: false, error: `missing required field(s): ${missingFields.join(', ')}` };
109+
}
110+
111+
return { valid: true, data };
112+
}
113+
114+
/**
115+
* Check if a README has a parent directory that also contains a README.
116+
* If true, this README is nested and doesn't need front matter validation.
117+
* @param {string} readmePath - Absolute path to README.md
118+
* @param {string} cookbookRoot - Absolute path to cookbook root
119+
* @returns {Promise<boolean>}
120+
*/
121+
async function hasParentReadme(readmePath, cookbookRoot) {
122+
// Start from the directory containing this README
123+
let currentDir = path.dirname(readmePath);
124+
125+
// Normalize paths for comparison
126+
const normalizedRoot = path.resolve(cookbookRoot);
127+
128+
// Walk up the directory tree
129+
while (currentDir !== normalizedRoot && currentDir !== path.dirname(currentDir)) {
130+
// Move to parent directory
131+
currentDir = path.dirname(currentDir);
132+
133+
// Stop if we've reached the cookbook root (don't check the root itself)
134+
if (currentDir === normalizedRoot) {
135+
break;
136+
}
137+
138+
// Stop if we've somehow passed the cookbook root
139+
if (currentDir.length < normalizedRoot.length) {
140+
break;
141+
}
142+
143+
// Check if parent directory has a README.md
144+
const parentReadme = path.join(currentDir, 'README.md');
145+
try {
146+
await fs.access(parentReadme);
147+
// Found a parent README - this README is nested
148+
return true;
149+
} catch {
150+
// No README in this parent directory, continue checking
151+
}
152+
}
153+
154+
// No parent README found - this is a top-level README
155+
return false;
156+
}
157+
158+
/**
159+
* Find all README.md files in the cookbook
160+
* @param {string} rootDir - Root directory to search
161+
* @returns {Promise<string[]>}
162+
*/
163+
async function findReadmeFiles(rootDir) {
164+
const stack = [rootDir];
165+
const readmes = [];
166+
167+
while (stack.length > 0) {
168+
const current = stack.pop();
169+
let entries;
170+
171+
try {
172+
entries = await fs.readdir(current, { withFileTypes: true });
173+
} catch (error) {
174+
// Skip directories we can't read
175+
continue;
176+
}
177+
178+
for (const entry of entries) {
179+
if (entry.name === '.git' || entry.name === '.github') {
180+
continue;
181+
}
182+
183+
const entryPath = path.join(current, entry.name);
184+
185+
if (entry.isDirectory()) {
186+
stack.push(entryPath);
187+
continue;
188+
}
189+
190+
if (entry.isFile() && entry.name.toLowerCase() === 'readme.md') {
191+
readmes.push(entryPath);
192+
}
193+
}
194+
}
195+
196+
return readmes;
197+
}
198+
199+
/**
200+
* Main validation function
201+
*/
202+
async function main() {
203+
console.log(`[validate-frontmatter] Checking cookbook at: ${COOKBOOK_DIR}\n`);
204+
205+
// Check if cookbook directory exists
206+
try {
207+
await fs.access(COOKBOOK_DIR);
208+
} catch (error) {
209+
console.error(`Error: Cookbook directory not found: ${COOKBOOK_DIR}`);
210+
process.exit(1);
211+
}
212+
213+
// Find all README files
214+
const allReadmes = await findReadmeFiles(COOKBOOK_DIR);
215+
216+
if (allReadmes.length === 0) {
217+
console.warn('Warning: No README.md files found in cookbook');
218+
process.exit(0);
219+
}
220+
221+
// Filter to top-level recipe READMEs (those without a parent README)
222+
const recipeReadmes = [];
223+
for (const readme of allReadmes) {
224+
// Skip root-level README (repository documentation)
225+
if (path.dirname(readme) === path.resolve(COOKBOOK_DIR)) {
226+
continue;
227+
}
228+
229+
const isNested = await hasParentReadme(readme, COOKBOOK_DIR);
230+
if (!isNested) {
231+
recipeReadmes.push(readme);
232+
}
233+
}
234+
235+
console.log(`Found ${recipeReadmes.length} top-level recipe README(s) to validate`);
236+
console.log(`(${allReadmes.length - recipeReadmes.length} nested README(s) skipped)\n`);
237+
238+
// Validate each recipe README
239+
const errors = [];
240+
241+
for (const readmePath of recipeReadmes) {
242+
const relativePath = path.relative(COOKBOOK_DIR, readmePath);
243+
244+
let content;
245+
try {
246+
content = await fs.readFile(readmePath, 'utf8');
247+
} catch (error) {
248+
errors.push({ file: relativePath, error: `cannot read file: ${error.message}` });
249+
continue;
250+
}
251+
252+
const validation = validateFrontMatter(content);
253+
254+
if (!validation.valid) {
255+
errors.push({ file: relativePath, error: validation.error });
256+
} else {
257+
console.log(`✓ ${relativePath}`);
258+
}
259+
}
260+
261+
// Report results
262+
console.log('');
263+
264+
if (errors.length > 0) {
265+
console.error(`❌ Found ${errors.length} error(s):\n`);
266+
for (const { file, error } of errors) {
267+
console.error(` ${file}`);
268+
console.error(` → ${error}\n`);
269+
}
270+
process.exit(1);
271+
} else {
272+
console.log(`✅ All ${recipeReadmes.length} recipe README(s) have valid front matter`);
273+
process.exit(0);
274+
}
275+
}
276+
277+
// Run the validation
278+
main().catch(error => {
279+
console.error('Unexpected error:', error);
280+
process.exit(1);
281+
});
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
name: Validate Recipe Front Matter
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
pull_request:
8+
branches:
9+
- main
10+
paths:
11+
- '**/README.md'
12+
- '.github/scripts/validate-frontmatter.js'
13+
- '.github/workflows/validate-frontmatter.yml'
14+
15+
concurrency:
16+
group: validate-frontmatter-${{ github.ref }}
17+
cancel-in-progress: true
18+
19+
permissions:
20+
contents: read
21+
22+
jobs:
23+
validate:
24+
name: Check Recipe Front Matter
25+
runs-on: ubuntu-latest
26+
27+
steps:
28+
- name: Checkout ai-cookbook repository
29+
uses: actions/checkout@v4
30+
31+
- name: Setup Node.js
32+
uses: actions/setup-node@v4
33+
with:
34+
node-version: "20"
35+
36+
- name: Make validation script executable
37+
run: chmod +x .github/scripts/validate-frontmatter.js
38+
39+
- name: Validate front matter in recipe READMEs
40+
run: node .github/scripts/validate-frontmatter.js
41+
42+
- name: Report success
43+
if: success()
44+
run: |
45+
echo "✅ All recipe READMEs have valid front matter"
46+
echo ""
47+
echo "This validates that all top-level recipe READMEs contain required front matter fields."

0 commit comments

Comments
 (0)