Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
c856887
feat(.ai): .ai/ dir for tool-agnostic support
marissahuysentruyt Apr 3, 2026
60dee75
refactor(.ai): convert accessibility-migration-analysis from rule to …
marissahuysentruyt Apr 3, 2026
ad25fb7
docs(.ai): updates readme with .ai references and new skills
marissahuysentruyt Apr 3, 2026
e1f184a
chore(.cursor): remove files relocated to .ai/
marissahuysentruyt Apr 3, 2026
0395c95
feat(.ai): adds .cursor symlinks
marissahuysentruyt Apr 3, 2026
d63c149
feat(.ai): add .claude symlinks
marissahuysentruyt Apr 3, 2026
03fc71c
fix(.ai): updates path references in rules and scripts to .ai
marissahuysentruyt Apr 3, 2026
5aee790
feat: adds new directories to gitignore
marissahuysentruyt Apr 3, 2026
35a8e58
feat: repo level AGENTS.md
marissahuysentruyt Apr 3, 2026
ee07e57
docs(.ai): tweaks to readme language
marissahuysentruyt Apr 3, 2026
78b3dc8
docs(.ai): overview and reusable prompts
marissahuysentruyt Apr 6, 2026
d9b6b08
docs(.ai): fix cursor reference in .ai/rule
marissahuysentruyt Apr 6, 2026
3d87dbe
fix(.ai): cursor skills symlink
marissahuysentruyt Apr 6, 2026
bcc6554
feat(.ai): adds missing frontmatter to storybook rules
marissahuysentruyt Apr 6, 2026
373dc21
fix(.ai): contributor docs nav scripts stay out of .ai
marissahuysentruyt Apr 6, 2026
095103b
fix(.ai): handoff references .ai instead of .cursor
marissahuysentruyt Apr 6, 2026
15b52b3
docs(.ai): clarify symlink usage in readme
marissahuysentruyt Apr 6, 2026
5419ec0
fix(.ai): missing instructions in a11y migration skill
marissahuysentruyt Apr 6, 2026
117b2a4
docs(.ai): update readme with content from agnostic overview file
marissahuysentruyt Apr 6, 2026
cca68b6
feat(.ai): add CI validation scripts for AI tooling
marissahuysentruyt Apr 3, 2026
7e69e97
fix: uses consistent grammar in workflow steps
marissahuysentruyt Apr 3, 2026
ce0adec
feat(.ai): script to validate symlinks
marissahuysentruyt Apr 6, 2026
74a0e0d
docs(.ai): ci and symlinking in readme
marissahuysentruyt Apr 6, 2026
a93e15a
chore: merge main
caseyisonit Apr 13, 2026
3c5010c
Merge branch 'main' into marissahuysentruyt/feat-ai-validation
rubencarvalho Apr 17, 2026
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
57 changes: 57 additions & 0 deletions .ai/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ All rules and skills now live in **`.ai/`** — a tool-agnostic, plain-markdown
- No sync step, no duplication, no drift between tools
- New contributors or tools start from `AGENTS.md` at the repo root, which bootstraps everything

## CI integration

- `yarn lint:ai` runs `.ai/scripts/validate.js`, which checks story tags, AGENTS.md paths, and config schema. Catches broken internal links, symlinks, and misconfigured rules before merge
- Pre-commit hook runs the contributor docs nav script to keep breadcrumbs and TOCs in sync automatically

## Rules

Rules defined in the `config.json` follow this structure:
Expand Down Expand Up @@ -359,6 +364,58 @@ Editing any `.ai/rules/*.md` file immediately updates what both Cursor and Claud
2. Register it in the skills catalog below and in [`AGENTS.md`](../AGENTS.md).
3. Both `.cursor/skills/` and `.claude/skills/` pick it up automatically via directory symlinks.

### Symlink setup

The symlinks in `.cursor/` and `.claude/` are committed to the repo, so **no setup is required after cloning**. Rules and skills should work automatically for all contributors.

#### Recreating broken symlinks

If a symlink is accidentally deleted or broken (e.g. after a file was deleted and recreated rather than edited in place), recreate it with the commands below.

##### Claude Code

```sh
mkdir -p .claude
ln -s ../.ai/rules .claude/rules
ln -s ../.ai/skills .claude/skills
```

Claude Code reads `.md` files, so directory-level symlinks work directly. Verify:

```sh
ls -la .claude/
# rules -> ../.ai/rules
# skills -> ../.ai/skills
```

##### Cursor

> **Cursor requires per-file symlinks for rules.** Cursor expects `.mdc` files and does not follow a directory symlink that contains `.md` files. Each rule needs its own symlink with the `.mdc` extension pointing back to the `.md` source.

```sh
mkdir -p .cursor/rules
for f in .ai/rules/*.md; do
name=$(basename "$f" .md)
ln -s "../../.ai/rules/${name}.md" ".cursor/rules/${name}.mdc"
done

ln -s ../.ai/skills .cursor/skills
```

Verify:

```sh
ls -la .cursor/rules/
# branch-naming.mdc -> ../../.ai/rules/branch-naming.md
# styles.mdc -> ../../.ai/rules/styles.md
# ... (one entry per rule)

ls -la .cursor/
# skills -> ../.ai/skills
```

If Cursor does not pick up the rules after symlinking, reload the window: `Cmd+Shift+P` → "Developer: Reload Window".

### Using rules and skills in other environments

If you use a tool that does not read `.cursor/` or `.claude/`, point it at `.ai/` directly:
Expand Down
128 changes: 128 additions & 0 deletions .ai/scripts/validate-agents-paths.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
#!/usr/bin/env node

/**
* Copyright 2026 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/

/**
* Validate that all relative paths referenced in AGENTS.md files resolve to real files.
*
* A broken path in AGENTS.md silently breaks agent bootstrapping — the agent never
* finds the guidance without any error. This check catches drift early.
*
* Checks:
* - Every relative markdown link in an AGENTS.md file points to an existing path
*
* Usage:
* node .ai/scripts/validate-agents-paths.js
*/

import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(__dirname, '../..');

// Directories to skip when searching for AGENTS.md files
const SKIP_DIRS = new Set([
'node_modules',
'.git',
'dist',
'.wireit',
'storybook-static',
'coverage',
]);

/**
* Recursively find all AGENTS.md files under a directory.
*/
function findAgentsFiles(dir) {
const results = [];

let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return results;
}

for (const entry of entries) {
if (entry.isDirectory()) {
if (!SKIP_DIRS.has(entry.name)) {
results.push(...findAgentsFiles(path.join(dir, entry.name)));
}
} else if (entry.isFile() && entry.name === 'AGENTS.md') {
results.push(path.join(dir, entry.name));
}
}

return results;
}

/**
* Extract relative markdown links from source text.
* Returns array of { href, line } — skips external URLs, pure anchors, and mailto.
*/
function extractRelativeLinks(source) {
const links = [];
const linkPattern = /\[([^\]]*)\]\(([^)]+)\)/g;
let match;

while ((match = linkPattern.exec(source)) !== null) {
const href = match[2].split('#')[0].trim(); // strip anchor fragment
if (
!href ||
href.startsWith('http://') ||
href.startsWith('https://') ||
href.startsWith('//') ||
href.startsWith('mailto:')
) {
continue;
}

const line = source.slice(0, match.index).split('\n').length;
links.push({ href, line });
}

return links;
}

/**
* Validate a single AGENTS.md file. Returns array of error strings.
*/
function validateFile(filePath) {
const errors = [];
const source = fs.readFileSync(filePath, 'utf-8');
const fileDir = path.dirname(filePath);
const rel = path.relative(repoRoot, filePath);

for (const { href, line } of extractRelativeLinks(source)) {
const resolved = path.resolve(fileDir, href);
if (!fs.existsSync(resolved)) {
errors.push(
`${rel}:${line}: broken link '${href}' — resolved to ${path.relative(repoRoot, resolved)}`
);
}
}

return errors;
}

/**
* Run validation across all AGENTS.md files. Returns { errors, fileCount }.
*/
export function validateAgentsPaths() {
const files = findAgentsFiles(repoRoot);
const errors = files.flatMap(validateFile);

return { errors, fileCount: files.length };
}
180 changes: 180 additions & 0 deletions .ai/scripts/validate-config-schema.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
#!/usr/bin/env node

/**
* Copyright 2026 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/

/**
* Validate the structure and content of .ai/config.json.
*
* Checks:
* - Required top-level sections are present
* - git.types is a non-empty array of strings
* - git.validationPattern is a valid regex
* - jira_tickets.title_format.max_length is a positive integer
* - jira_tickets.title_format.pattern is a valid regex
* - jira_tickets.labels keys and issue_types entries are non-empty strings
* - text_formatting.headings.case is a string
*
* Usage:
* node .ai/scripts/validate-config-schema.js
*/

import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const configPath = path.resolve(__dirname, '../config.json');

/**
* Try to compile a string as a RegExp. Returns an error message or null.
*/
function validateRegex(pattern, label) {
try {
new RegExp(pattern);
return null;
} catch (e) {
return `${label}: invalid regex '${pattern}' — ${e.message}`;
}
}

/**
* Validate .ai/config.json. Returns { errors, warnings }.
*/
export function validateConfigSchema() {
const errors = [];
const warnings = [];
const configRel = path.relative(path.resolve(__dirname, '../..'), configPath);

if (!fs.existsSync(configPath)) {
errors.push(`${configRel}: file not found`);
return { errors, warnings };
}

let config;
try {
config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
} catch (e) {
errors.push(`${configRel}: invalid JSON — ${e.message}`);
return { errors, warnings };
}

// ── git ──────────────────────────────────────────────────────────────────

if (!config.git) {
errors.push(`${configRel}: missing required section 'git'`);
} else {
const { git } = config;

if (!Array.isArray(git.types) || git.types.length === 0) {
errors.push(
`${configRel}: git.types must be a non-empty array of strings`
);
} else if (!git.types.every((t) => typeof t === 'string' && t)) {
errors.push(
`${configRel}: git.types must contain only non-empty strings`
);
}

if (git.validationPattern) {
const regexError = validateRegex(
git.validationPattern,
`${configRel}: git.validationPattern`
);
if (regexError) {
errors.push(regexError);
}
} else {
warnings.push(
`${configRel}: git.validationPattern is missing — branch name validation will not work`
);
}

if (!git.branchNameTemplate) {
warnings.push(`${configRel}: git.branchNameTemplate is missing`);
}
}

// ── jira_tickets ─────────────────────────────────────────────────────────

if (!config.jira_tickets) {
errors.push(`${configRel}: missing required section 'jira_tickets'`);
} else {
const { jira_tickets: jira } = config;

if (!jira.title_format) {
errors.push(`${configRel}: jira_tickets.title_format is required`);
} else {
const { max_length, pattern } = jira.title_format;

if (
typeof max_length !== 'number' ||
!Number.isInteger(max_length) ||
max_length <= 0
) {
errors.push(
`${configRel}: jira_tickets.title_format.max_length must be a positive integer`
);
}

if (pattern) {
const regexError = validateRegex(
pattern,
`${configRel}: jira_tickets.title_format.pattern`
);
if (regexError) {
errors.push(regexError);
}
} else {
warnings.push(
`${configRel}: jira_tickets.title_format.pattern is missing`
);
}
}

if (
!jira.labels ||
typeof jira.labels !== 'object' ||
Array.isArray(jira.labels)
) {
errors.push(
`${configRel}: jira_tickets.labels must be a non-null object`
);
} else if (Object.keys(jira.labels).length === 0) {
warnings.push(`${configRel}: jira_tickets.labels is empty`);
}

if (!Array.isArray(jira.issue_types) || jira.issue_types.length === 0) {
errors.push(
`${configRel}: jira_tickets.issue_types must be a non-empty array`
);
}

if (!Array.isArray(jira.required_sections)) {
errors.push(
`${configRel}: jira_tickets.required_sections must be an array`
);
}
}

// ── text_formatting ───────────────────────────────────────────────────────

if (!config.text_formatting) {
warnings.push(
`${configRel}: missing section 'text_formatting' — heading case rules will not apply`
);
} else if (!config.text_formatting.headings?.case) {
warnings.push(`${configRel}: text_formatting.headings.case is missing`);
}

return { errors, warnings };
}
Loading
Loading