diff --git a/.ai/README.md b/.ai/README.md index b911ab2a096..f6066d0813f 100644 --- a/.ai/README.md +++ b/.ai/README.md @@ -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: @@ -366,6 +371,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: diff --git a/.ai/scripts/validate-agents-paths.js b/.ai/scripts/validate-agents-paths.js new file mode 100644 index 00000000000..ae029a24b0a --- /dev/null +++ b/.ai/scripts/validate-agents-paths.js @@ -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 }; +} diff --git a/.ai/scripts/validate-config-schema.js b/.ai/scripts/validate-config-schema.js new file mode 100644 index 00000000000..5d1e8e90fba --- /dev/null +++ b/.ai/scripts/validate-config-schema.js @@ -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 }; +} diff --git a/.ai/scripts/validate-story-tags.js b/.ai/scripts/validate-story-tags.js new file mode 100644 index 00000000000..b4316e2c444 --- /dev/null +++ b/.ai/scripts/validate-story-tags.js @@ -0,0 +1,151 @@ +#!/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 Storybook story tags in 2nd-gen component stories files. + * + * Checks: + * - Every tag value is in the known allowed set + * - Every stories file has at least one `tags` declaration containing 'migrated' + * + * Usage: + * node .ai/scripts/validate-story-tags.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, '../..'); + +// Tags defined in .ai/rules/stories-format.md +const ALLOWED_TAGS = new Set([ + 'anatomy', + 'options', + 'states', + 'behaviors', + 'a11y', + 'overview', + 'migrated', + 'autodocs', + 'dev', + 'utility', + '!test', + '!dev', + '!autodocs', +]); + +/** + * Recursively find all *.stories.ts files under a directory. + */ +function findStoriesFiles(dir) { + const results = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + results.push(...findStoriesFiles(full)); + } else if (entry.isFile() && entry.name.endsWith('.stories.ts')) { + results.push(full); + } + } + return results; +} + +/** + * Extract all tag arrays from a file's source text. + * Returns an array of { tags: string[], line: number } objects. + */ +function extractTagDeclarations(source) { + const declarations = []; + + // Match `tags: [...]` — handles multi-line arrays + const tagsPattern = /tags:\s*\[([^\]]*)\]/gs; + let match; + + while ((match = tagsPattern.exec(source)) !== null) { + const arrayContent = match[1]; + const lineNumber = source.slice(0, match.index).split('\n').length; + + // Extract individual quoted tag values + const tagValues = []; + const valuePattern = /['"]([^'"]+)['"]/g; + let valueMatch; + while ((valueMatch = valuePattern.exec(arrayContent)) !== null) { + tagValues.push(valueMatch[1]); + } + + if (tagValues.length > 0) { + declarations.push({ tags: tagValues, line: lineNumber }); + } + } + + return declarations; +} + +/** + * Validate a single stories file. Returns array of error strings. + */ +function validateFile(filePath) { + const errors = []; + const source = fs.readFileSync(filePath, 'utf-8'); + const rel = path.relative(repoRoot, filePath); + const declarations = extractTagDeclarations(source); + + if (declarations.length === 0) { + errors.push(`${rel}: no tags declarations found`); + return errors; + } + + // Check all tags are in the allowed set + for (const { tags, line } of declarations) { + for (const tag of tags) { + if (!ALLOWED_TAGS.has(tag)) { + errors.push( + `${rel}:${line}: unknown tag '${tag}' — allowed: ${[...ALLOWED_TAGS].sort().join(', ')}` + ); + } + } + } + + // Internal stories files (*.internal.stories.ts) like swc-icon are dev-only and do not + // need the 'migrated' tag. All other stories files do. + const isInternal = path.basename(filePath).includes('.internal.'); + if (!isInternal) { + const hasMigrated = declarations.some(({ tags }) => + tags.includes('migrated') + ); + if (!hasMigrated) { + errors.push(`${rel}: missing required 'migrated' tag on the meta object`); + } + } + + return errors; +} + +/** + * Run validation across all stories files. Returns { errors, fileCount }. + */ +export function validateStoryTags() { + const storiesRoot = path.join(repoRoot, '2nd-gen/packages/swc/components'); + + if (!fs.existsSync(storiesRoot)) { + return { errors: [], fileCount: 0 }; + } + + const files = findStoriesFiles(storiesRoot); + const errors = files.flatMap(validateFile); + + return { errors, fileCount: files.length }; +} diff --git a/.ai/scripts/validate-symlinks.js b/.ai/scripts/validate-symlinks.js new file mode 100644 index 00000000000..b969edd68c7 --- /dev/null +++ b/.ai/scripts/validate-symlinks.js @@ -0,0 +1,114 @@ +// 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. + +/** + * Validates that the .cursor/ and .claude/ adapter symlinks correctly point to + * their .ai/ canonical sources. + * + * Cursor rules: per-file symlinks (.cursor/rules/.mdc → ../../.ai/rules/.md) + * Cursor skills: directory symlink (.cursor/skills → ../.ai/skills) + * Claude rules: directory symlink (.claude/rules → ../.ai/rules) + * Claude skills: directory symlink (.claude/skills → ../.ai/skills) + * + * Returns { errors, fileCount } for integration with validate.js. + */ + +import { existsSync, lstatSync, readdirSync, readlinkSync } from 'fs'; +import { basename, join } from 'path'; + +const ROOT = new URL('../../', import.meta.url).pathname.replace(/\/$/, ''); + +function checkDirectorySymlink(linkPath, expectedTarget, errors) { + const rel = linkPath.replace(ROOT + '/', ''); + if (!existsSync(linkPath)) { + errors.push( + `${rel} does not exist — run one-time setup (see .ai/README.md)` + ); + return; + } + const stat = lstatSync(linkPath); + if (!stat.isSymbolicLink()) { + errors.push(`${rel} exists but is not a symlink`); + return; + } + const actual = readlinkSync(linkPath); + if (actual !== expectedTarget) { + errors.push(`${rel} points to "${actual}", expected "${expectedTarget}"`); + } +} + +function checkCursorRuleSymlinks(errors) { + const rulesDir = join(ROOT, '.ai/rules'); + const cursorRulesDir = join(ROOT, '.cursor/rules'); + let checks = 0; + + const sourceFiles = readdirSync(rulesDir) + .filter((f) => f.endsWith('.md')) + .map((f) => basename(f, '.md')); + + for (const name of sourceFiles) { + checks++; + const linkPath = join(cursorRulesDir, `${name}.mdc`); + const expectedTarget = `../../.ai/rules/${name}.md`; + const rel = linkPath.replace(ROOT + '/', ''); + + let stat; + try { + stat = lstatSync(linkPath); + } catch { + errors.push( + `${rel} does not exist — run one-time setup (see .ai/README.md)` + ); + continue; + } + + if (!stat.isSymbolicLink()) { + errors.push(`${rel} exists but is not a symlink`); + continue; + } + + const actual = readlinkSync(linkPath); + if (actual !== expectedTarget) { + errors.push(`${rel} points to "${actual}", expected "${expectedTarget}"`); + } + } + + // Check for stale .mdc symlinks (no matching .ai/rules/*.md source) + if (existsSync(cursorRulesDir)) { + const cursorFiles = readdirSync(cursorRulesDir).filter((f) => + f.endsWith('.mdc') + ); + for (const file of cursorFiles) { + const name = basename(file, '.mdc'); + if (!sourceFiles.includes(name)) { + checks++; + errors.push( + `.cursor/rules/${file} has no matching .ai/rules/${name}.md — stale symlink` + ); + } + } + } + + return checks; +} + +/** + * Run validation across all adapter symlinks. Returns { errors, fileCount }. + */ +export function validateSymlinks() { + const errors = []; + + const ruleChecks = checkCursorRuleSymlinks(errors); + checkDirectorySymlink(join(ROOT, '.cursor/skills'), '../.ai/skills', errors); + checkDirectorySymlink(join(ROOT, '.claude/rules'), '../.ai/rules', errors); + checkDirectorySymlink(join(ROOT, '.claude/skills'), '../.ai/skills', errors); + + return { errors, fileCount: ruleChecks + 3 }; +} diff --git a/.ai/scripts/validate.js b/.ai/scripts/validate.js new file mode 100644 index 00000000000..fe626c03cfd --- /dev/null +++ b/.ai/scripts/validate.js @@ -0,0 +1,101 @@ +#!/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. + */ + +/** + * Entry point for AI tooling CI validation. + * + * Runs four checks: + * 1. Story tags — valid tags in 2nd-gen *.stories.ts files + * 2. AGENTS.md paths — relative links in AGENTS.md files resolve to real files + * 3. Config schema — .ai/config.json structure and regex validity + * 4. Symlinks — .cursor/ and .claude/ adapter symlinks point to .ai/ sources + * + * Exits with code 1 if any check has errors; warnings are printed but do not fail. + * + * Usage: + * node .ai/scripts/validate.js + */ + +import { validateAgentsPaths } from './validate-agents-paths.js'; +import { validateConfigSchema } from './validate-config-schema.js'; +import { validateStoryTags } from './validate-story-tags.js'; +import { validateSymlinks } from './validate-symlinks.js'; + +const RESET = '\x1b[0m'; +const RED = '\x1b[31m'; +const YELLOW = '\x1b[33m'; +const GREEN = '\x1b[32m'; +const BOLD = '\x1b[1m'; +const DIM = '\x1b[2m'; + +function printSection(title, errors, warnings, fileCount) { + const status = + errors.length > 0 + ? `${RED}✖ ${errors.length} error(s)${RESET}` + : `${GREEN}✔ passed${RESET}`; + + console.log( + `\n${BOLD}${title}${RESET} ${DIM}(${fileCount} file(s))${RESET} — ${status}` + ); + + for (const w of warnings) { + console.log(` ${YELLOW}⚠${RESET} ${w}`); + } + for (const e of errors) { + console.log(` ${RED}✖${RESET} ${e}`); + } +} + +let totalErrors = 0; + +// 1. Story tags +const tags = validateStoryTags(); +totalErrors += tags.errors.length; +printSection('Story tags', tags.errors, [], tags.fileCount); + +// 2. AGENTS.md paths +const agents = validateAgentsPaths(); +totalErrors += agents.errors.length; +printSection('AGENTS.md paths', agents.errors, [], agents.fileCount); + +// 3. Config schema +const config = validateConfigSchema(); +totalErrors += config.errors.length; +printSection( + 'Config schema (.ai/config.json)', + config.errors, + config.warnings, + 1 +); + +// 4. Symlinks +const symlinks = validateSymlinks(); +totalErrors += symlinks.errors.length; +printSection( + 'Symlinks (.cursor/ and .claude/)', + symlinks.errors, + [], + symlinks.fileCount +); + +// Summary +console.log(''); +if (totalErrors > 0) { + console.log( + `${RED}${BOLD}${totalErrors} error(s) found. Fix the issues above before merging.${RESET}` + ); + process.exit(1); +} else { + console.log(`${GREEN}${BOLD}All checks passed.${RESET}`); +} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 20817d5288a..7603738093d 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -138,3 +138,6 @@ jobs: if [ -n "$FILES" ]; then yarn prettier --check $FILES fi + + - name: Validate AI tooling + run: yarn lint:ai diff --git a/package.json b/package.json index 8d198a13275..af971e1040e 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,8 @@ "format:eslint": "eslint ${LINT_PATH:-.} --cache --fix", "format:prettier": "prettier ${LINT_PATH:-.} --cache --write", "format:styles": "stylelint '${LINT_PATH:+$LINT_PATH/**/*.css}${LINT_PATH:-**/*.css}' --cache --fix --allow-empty-input", - "lint": "run-s --continue-on-error lint:eslint lint:styles lint:prettier", + "lint": "run-s --continue-on-error lint:eslint lint:styles lint:prettier lint:ai", + "lint:ai": "node .ai/scripts/validate.js", "lint:1st-gen": "LINT_PATH=1st-gen yarn lint", "lint:2nd-gen": "LINT_PATH=2nd-gen yarn lint", "lint:eslint": "echo 'Linting with ESLint...' && eslint ${LINT_PATH:-.} --cache",