Skip to content

Commit fa10bc7

Browse files
feat(.ai): add CI validation scripts for AI tooling
- Add validate.js entry point running 3 checks, exits with code 1 on errors - Add validate-agents-paths.js — checks relative links in AGENTS.md files - Add validate-config-schema.js — validates .ai/config.json structure - Add validate-story-tags.js — validates tags in 2nd-gen stories files - Add lint:ai script to package.json; include in overall lint command - Add AI tooling validation step to .github/workflows/lint.yml Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 6a6063b commit fa10bc7

File tree

6 files changed

+553
-1
lines changed

6 files changed

+553
-1
lines changed
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
#!/usr/bin/env node
2+
3+
/**
4+
* Copyright 2026 Adobe. All rights reserved.
5+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License. You may obtain a copy
7+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
8+
*
9+
* Unless required by applicable law or agreed to in writing, software distributed under
10+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
11+
* OF ANY KIND, either express or implied. See the License for the specific language
12+
* governing permissions and limitations under the License.
13+
*/
14+
15+
/**
16+
* Validate that all relative paths referenced in AGENTS.md files resolve to real files.
17+
*
18+
* A broken path in AGENTS.md silently breaks agent bootstrapping — the agent never
19+
* finds the guidance without any error. This check catches drift early.
20+
*
21+
* Checks:
22+
* - Every relative markdown link in an AGENTS.md file points to an existing path
23+
*
24+
* Usage:
25+
* node .ai/scripts/validate-agents-paths.js
26+
*/
27+
28+
import fs from 'fs';
29+
import path from 'path';
30+
import { fileURLToPath } from 'url';
31+
32+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
33+
const repoRoot = path.resolve(__dirname, '../..');
34+
35+
// Directories to skip when searching for AGENTS.md files
36+
const SKIP_DIRS = new Set([
37+
'node_modules',
38+
'.git',
39+
'dist',
40+
'.wireit',
41+
'storybook-static',
42+
'coverage',
43+
]);
44+
45+
/**
46+
* Recursively find all AGENTS.md files under a directory.
47+
*/
48+
function findAgentsFiles(dir) {
49+
const results = [];
50+
51+
let entries;
52+
try {
53+
entries = fs.readdirSync(dir, { withFileTypes: true });
54+
} catch {
55+
return results;
56+
}
57+
58+
for (const entry of entries) {
59+
if (entry.isDirectory()) {
60+
if (!SKIP_DIRS.has(entry.name)) {
61+
results.push(...findAgentsFiles(path.join(dir, entry.name)));
62+
}
63+
} else if (entry.isFile() && entry.name === 'AGENTS.md') {
64+
results.push(path.join(dir, entry.name));
65+
}
66+
}
67+
68+
return results;
69+
}
70+
71+
/**
72+
* Extract relative markdown links from source text.
73+
* Returns array of { href, line } — skips external URLs, pure anchors, and mailto.
74+
*/
75+
function extractRelativeLinks(source) {
76+
const links = [];
77+
const linkPattern = /\[([^\]]*)\]\(([^)]+)\)/g;
78+
let match;
79+
80+
while ((match = linkPattern.exec(source)) !== null) {
81+
const href = match[2].split('#')[0].trim(); // strip anchor fragment
82+
if (
83+
!href ||
84+
href.startsWith('http://') ||
85+
href.startsWith('https://') ||
86+
href.startsWith('//') ||
87+
href.startsWith('mailto:')
88+
) {
89+
continue;
90+
}
91+
92+
const line = source.slice(0, match.index).split('\n').length;
93+
links.push({ href, line });
94+
}
95+
96+
return links;
97+
}
98+
99+
/**
100+
* Validate a single AGENTS.md file. Returns array of error strings.
101+
*/
102+
function validateFile(filePath) {
103+
const errors = [];
104+
const source = fs.readFileSync(filePath, 'utf-8');
105+
const fileDir = path.dirname(filePath);
106+
const rel = path.relative(repoRoot, filePath);
107+
108+
for (const { href, line } of extractRelativeLinks(source)) {
109+
const resolved = path.resolve(fileDir, href);
110+
if (!fs.existsSync(resolved)) {
111+
errors.push(
112+
`${rel}:${line}: broken link '${href}' — resolved to ${path.relative(repoRoot, resolved)}`
113+
);
114+
}
115+
}
116+
117+
return errors;
118+
}
119+
120+
/**
121+
* Run validation across all AGENTS.md files. Returns { errors, fileCount }.
122+
*/
123+
export function validateAgentsPaths() {
124+
const files = findAgentsFiles(repoRoot);
125+
const errors = files.flatMap(validateFile);
126+
127+
return { errors, fileCount: files.length };
128+
}
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
#!/usr/bin/env node
2+
3+
/**
4+
* Copyright 2026 Adobe. All rights reserved.
5+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License. You may obtain a copy
7+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
8+
*
9+
* Unless required by applicable law or agreed to in writing, software distributed under
10+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
11+
* OF ANY KIND, either express or implied. See the License for the specific language
12+
* governing permissions and limitations under the License.
13+
*/
14+
15+
/**
16+
* Validate the structure and content of .ai/config.json.
17+
*
18+
* Checks:
19+
* - Required top-level sections are present
20+
* - git.types is a non-empty array of strings
21+
* - git.validationPattern is a valid regex
22+
* - jira_tickets.title_format.max_length is a positive integer
23+
* - jira_tickets.title_format.pattern is a valid regex
24+
* - jira_tickets.labels keys and issue_types entries are non-empty strings
25+
* - text_formatting.headings.case is a string
26+
*
27+
* Usage:
28+
* node .ai/scripts/validate-config-schema.js
29+
*/
30+
31+
import fs from 'fs';
32+
import path from 'path';
33+
import { fileURLToPath } from 'url';
34+
35+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
36+
const configPath = path.resolve(__dirname, '../config.json');
37+
38+
/**
39+
* Try to compile a string as a RegExp. Returns an error message or null.
40+
*/
41+
function validateRegex(pattern, label) {
42+
try {
43+
new RegExp(pattern);
44+
return null;
45+
} catch (e) {
46+
return `${label}: invalid regex '${pattern}' — ${e.message}`;
47+
}
48+
}
49+
50+
/**
51+
* Validate .ai/config.json. Returns { errors, warnings }.
52+
*/
53+
export function validateConfigSchema() {
54+
const errors = [];
55+
const warnings = [];
56+
const configRel = path.relative(path.resolve(__dirname, '../..'), configPath);
57+
58+
if (!fs.existsSync(configPath)) {
59+
errors.push(`${configRel}: file not found`);
60+
return { errors, warnings };
61+
}
62+
63+
let config;
64+
try {
65+
config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
66+
} catch (e) {
67+
errors.push(`${configRel}: invalid JSON — ${e.message}`);
68+
return { errors, warnings };
69+
}
70+
71+
// ── git ──────────────────────────────────────────────────────────────────
72+
73+
if (!config.git) {
74+
errors.push(`${configRel}: missing required section 'git'`);
75+
} else {
76+
const { git } = config;
77+
78+
if (!Array.isArray(git.types) || git.types.length === 0) {
79+
errors.push(
80+
`${configRel}: git.types must be a non-empty array of strings`
81+
);
82+
} else if (!git.types.every((t) => typeof t === 'string' && t)) {
83+
errors.push(
84+
`${configRel}: git.types must contain only non-empty strings`
85+
);
86+
}
87+
88+
if (git.validationPattern) {
89+
const regexError = validateRegex(
90+
git.validationPattern,
91+
`${configRel}: git.validationPattern`
92+
);
93+
if (regexError) {
94+
errors.push(regexError);
95+
}
96+
} else {
97+
warnings.push(
98+
`${configRel}: git.validationPattern is missing — branch name validation will not work`
99+
);
100+
}
101+
102+
if (!git.branchNameTemplate) {
103+
warnings.push(`${configRel}: git.branchNameTemplate is missing`);
104+
}
105+
}
106+
107+
// ── jira_tickets ─────────────────────────────────────────────────────────
108+
109+
if (!config.jira_tickets) {
110+
errors.push(`${configRel}: missing required section 'jira_tickets'`);
111+
} else {
112+
const { jira_tickets: jira } = config;
113+
114+
if (!jira.title_format) {
115+
errors.push(`${configRel}: jira_tickets.title_format is required`);
116+
} else {
117+
const { max_length, pattern } = jira.title_format;
118+
119+
if (
120+
typeof max_length !== 'number' ||
121+
!Number.isInteger(max_length) ||
122+
max_length <= 0
123+
) {
124+
errors.push(
125+
`${configRel}: jira_tickets.title_format.max_length must be a positive integer`
126+
);
127+
}
128+
129+
if (pattern) {
130+
const regexError = validateRegex(
131+
pattern,
132+
`${configRel}: jira_tickets.title_format.pattern`
133+
);
134+
if (regexError) {
135+
errors.push(regexError);
136+
}
137+
} else {
138+
warnings.push(
139+
`${configRel}: jira_tickets.title_format.pattern is missing`
140+
);
141+
}
142+
}
143+
144+
if (
145+
!jira.labels ||
146+
typeof jira.labels !== 'object' ||
147+
Array.isArray(jira.labels)
148+
) {
149+
errors.push(
150+
`${configRel}: jira_tickets.labels must be a non-null object`
151+
);
152+
} else if (Object.keys(jira.labels).length === 0) {
153+
warnings.push(`${configRel}: jira_tickets.labels is empty`);
154+
}
155+
156+
if (!Array.isArray(jira.issue_types) || jira.issue_types.length === 0) {
157+
errors.push(
158+
`${configRel}: jira_tickets.issue_types must be a non-empty array`
159+
);
160+
}
161+
162+
if (!Array.isArray(jira.required_sections)) {
163+
errors.push(
164+
`${configRel}: jira_tickets.required_sections must be an array`
165+
);
166+
}
167+
}
168+
169+
// ── text_formatting ───────────────────────────────────────────────────────
170+
171+
if (!config.text_formatting) {
172+
warnings.push(
173+
`${configRel}: missing section 'text_formatting' — heading case rules will not apply`
174+
);
175+
} else if (!config.text_formatting.headings?.case) {
176+
warnings.push(`${configRel}: text_formatting.headings.case is missing`);
177+
}
178+
179+
return { errors, warnings };
180+
}

0 commit comments

Comments
 (0)