From 0be520226d7d3f5a5c8d507fbaeba4788b0f5994 Mon Sep 17 00:00:00 2001 From: Antonio Mancini Date: Sun, 11 Oct 2026 13:50:09 +0200 Subject: [PATCH 1/2] fix: framework-aware typecheck and keep linters off vendored skills - web preset: default typecheck to astro check, nuxi typecheck, svelte-check or vue-tsc when the framework is a dependency (adding the checker package), and to build mode for solution-style tsconfigs, where tsc --noEmit checks nothing (fresh Vite projects passed with type errors) - init/update: add .agents/ and .claude/ to .prettierignore and .eslintignore (legacy ESLint, oxlint) and to flat-config global ignores when the config has an unambiguous ignores array; otherwise warn with the exact fix - doctor: report linters/formatters that still see vendored skills --- src/adapters/tool-ignores.ts | 145 +++++++++++++++++++++++++++++++++++ src/cli/doctor.ts | 17 ++++ src/cli/main.ts | 5 ++ src/cli/new.ts | 5 ++ src/presets/web.ts | 38 ++++++++- tests/presets.test.ts | 52 +++++++++++++ tests/tool-ignores.test.ts | 74 ++++++++++++++++++ 7 files changed, 333 insertions(+), 3 deletions(-) create mode 100644 src/adapters/tool-ignores.ts create mode 100644 tests/tool-ignores.test.ts diff --git a/src/adapters/tool-ignores.ts b/src/adapters/tool-ignores.ts new file mode 100644 index 0000000..56fdb0d --- /dev/null +++ b/src/adapters/tool-ignores.ts @@ -0,0 +1,145 @@ +import path from 'node:path'; +import { fileExists, readTextFile, safeWriteFile } from '../utils/fs.js'; +import { readPackageManifest } from '../config/validation.js'; + +// Vendored skill sources ship third-party JS and Markdown that the project's own +// linters and formatters must not check. +const VENDORED_DIRS = ['.agents', '.claude']; + +const PRETTIER_CONFIGS = [ + '.prettierrc', + '.prettierrc.json', + '.prettierrc.json5', + '.prettierrc.yaml', + '.prettierrc.yml', + '.prettierrc.toml', + '.prettierrc.js', + '.prettierrc.cjs', + '.prettierrc.mjs', + '.prettierrc.ts', + '.prettierrc.cts', + '.prettierrc.mts', + 'prettier.config.js', + 'prettier.config.cjs', + 'prettier.config.mjs', + 'prettier.config.ts', + 'prettier.config.cts', + 'prettier.config.mts' +]; +const LEGACY_ESLINT_CONFIGS = [ + '.eslintrc', + '.eslintrc.js', + '.eslintrc.cjs', + '.eslintrc.json', + '.eslintrc.yaml', + '.eslintrc.yml' +]; +const FLAT_ESLINT_CONFIGS = [ + 'eslint.config.js', + 'eslint.config.mjs', + 'eslint.config.cjs', + 'eslint.config.ts', + 'eslint.config.mts', + 'eslint.config.cts' +]; + +interface IgnorePlan { + /** Ignore files (gitignore syntax) and the directory lines they lack. */ + ignoreFiles: Array<{ file: string; missing: string[] }>; + /** Flat ESLint config and the directories its ignores lack. */ + flatConfig?: { file: string; missing: string[] }; +} + +function ignoreFileMissing(text: string): string[] { + const lines = text.split(/\r?\n/).map((line) => line.trim()); + return VENDORED_DIRS.filter( + (dir) => + !lines.some((line) => new RegExp(`^/?${dir.replace('.', '\\.')}(/(\\*\\*)?)?$`).test(line)) + ); +} + +function planIgnores(targetDir: string): IgnorePlan { + const pkg = readPackageManifest(targetDir); + const has = (name: string) => Boolean(pkg?.dependencies?.[name] || pkg?.devDependencies?.[name]); + const exists = (name: string) => fileExists(path.join(targetDir, name)); + const plan: IgnorePlan = { ignoreFiles: [] }; + const addIgnoreFile = (file: string) => { + const missing = ignoreFileMissing(readTextFile(path.join(targetDir, file)) || ''); + if (missing.length > 0) plan.ignoreFiles.push({ file, missing }); + }; + + if (has('prettier') || pkg?.prettier || PRETTIER_CONFIGS.some(exists)) + addIgnoreFile('.prettierignore'); + // Legacy ESLint and oxlint both read .eslintignore; flat-config ESLint does not. + if (has('oxlint') || pkg?.eslintConfig || LEGACY_ESLINT_CONFIGS.some(exists)) + addIgnoreFile('.eslintignore'); + + const flat = FLAT_ESLINT_CONFIGS.find(exists); + if (flat) { + const text = readTextFile(path.join(targetDir, flat)) || ''; + const missing = VENDORED_DIRS.filter((dir) => !text.includes(`${dir}/`)); + if (missing.length > 0) plan.flatConfig = { file: flat, missing }; + } + return plan; +} + +/** Adds the vendored directories to a global ignores array, if the config has an + * unambiguous one: `globalIgnores([...])` or an object holding only `ignores`. */ +function patchFlatConfig(text: string, dirs: string[]): string | null { + const match = + /globalIgnores\(\s*\[/.exec(text) ?? /\{\s*ignores\s*:\s*\[(?=[^[\]]*\]\s*,?\s*\})/.exec(text); + if (!match) return null; + const at = match.index + match[0].length; + const rest = text.slice(at); + const quote = + /^\s*(['"`])/.exec(rest)?.[1] ?? + ((text.match(/"/g)?.length ?? 0) > (text.match(/'/g)?.length ?? 0) ? '"' : "'"); + const entries = dirs.map((dir) => `${quote}${dir}/**${quote}`); + const indent = /^(\s*\n[ \t]*)/.exec(rest)?.[1]; + const insertion = indent + ? entries.map((entry) => `${indent}${entry},`).join('') + : /^\s*\]/.test(rest) + ? entries.join(', ') + : entries.join(', ') + ', '; + return text.slice(0, at) + insertion + rest; +} + +function flatConfigAdvice(file: string, dirs: string[]): string { + return `add { ignores: [${dirs.map((dir) => `'${dir}/**'`).join(', ')}] } to the exported array in ${file}`; +} + +/** Keeps the project's linters and formatters off vendored skills. Returns the + * advice for configs that could not be edited safely. */ +export function ignoreVendoredSkills( + targetDir: string, + options: { dryRun?: boolean; silent?: boolean } = {} +): string[] { + const { dryRun = false, silent = false } = options; + const plan = planIgnores(targetDir); + for (const { file, missing } of plan.ignoreFiles) { + const filePath = path.join(targetDir, file); + const existing = readTextFile(filePath); + const lines = missing.map((dir) => `${dir}/`).join('\n'); + const updated = existing + ? `${existing.trimEnd()}\n\n# Vendored agent skills (aginit)\n${lines}\n` + : `# Vendored agent skills (aginit)\n${lines}\n`; + safeWriteFile(filePath, updated, { overwrite: true, dryRun, silent }); + } + if (!plan.flatConfig) return []; + const { file, missing } = plan.flatConfig; + const filePath = path.join(targetDir, file); + const patched = patchFlatConfig(readTextFile(filePath) || '', missing); + if (patched === null) return [`ESLint lints vendored skills: ${flatConfigAdvice(file, missing)}`]; + safeWriteFile(filePath, patched, { overwrite: true, dryRun, silent }); + return []; +} + +/** Doctor view: every config that still lets a linter or formatter see vendored skills. */ +export function vendoredSkillsExposure(targetDir: string): string[] { + const plan = planIgnores(targetDir); + const issues = plan.ignoreFiles.map( + ({ file, missing }) => `add ${missing.map((dir) => `${dir}/`).join(' and ')} to ${file}` + ); + if (plan.flatConfig) issues.push(flatConfigAdvice(plan.flatConfig.file, plan.flatConfig.missing)); + return issues; +} diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index 291eb62..d4fe19d 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -23,6 +23,7 @@ import { runAgentBrowserDiagnostics } from '../adapters/agent-browser.js'; import { readProjectConfig } from '../config/validation.js'; +import { vendoredSkillsExposure } from '../adapters/tool-ignores.js'; import { logger } from '../utils/logger.js'; interface CheckItem { @@ -226,6 +227,22 @@ export async function runDoctor(targetDir: string = process.cwd()): Promise 0) { + const exposure = vendoredSkillsExposure(targetDir); + checks.push({ + name: 'Lint/format ignores for vendored skills', + category: 'Project Config', + ok: exposure.length === 0, + detail: + exposure.length === 0 + ? 'Detected linters and formatters skip .agents/ and .claude/' + : 'Project linters or formatters will check vendored skill files', + suggestion: + exposure.length === 0 ? undefined : `Run \`aginit update\`, or ${exposure.join('; ')}` + }); + } + // 9b. Claude Code: skills linked into .claude/skills/ and AGENTS.md reachable if (projectConfig !== null && claudeCodeEnabled(projectConfig)) { if (!skillsDisabled && installedSkills.length > 0) { diff --git a/src/cli/main.ts b/src/cli/main.ts index a7ad9a8..4459dbc 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -4,6 +4,7 @@ import { createProject } from './new.js'; import { initCurrentDirectory } from './init.js'; import { runDoctor } from './doctor.js'; import { claudeCodeEnabled, ensureClaudeSkillLinks, updateSkills } from '../adapters/skills.js'; +import { ignoreVendoredSkills } from '../adapters/tool-ignores.js'; import { setupClaudeMarkdown } from '../adapters/agents-md.js'; import { buildGraft } from '../adapters/graft.js'; import { readProjectConfig } from '../config/validation.js'; @@ -168,6 +169,10 @@ export function createCliProgram(): Command { (config ? await ensureClaudeSkillLinks(targetDir, config, { dryRun: options.dryRun }) : true); if (config && claudeCodeEnabled(config)) setupClaudeMarkdown(targetDir, { dryRun: options.dryRun }); + if (config?.skills.sources.length !== 0) { + const advice = ignoreVendoredSkills(targetDir, { dryRun: options.dryRun, silent: true }); + for (const line of advice) logger.warn(line); + } const graftOk = config?.codebase.graft === false ? true diff --git a/src/cli/new.ts b/src/cli/new.ts index 336c266..d01bcdc 100644 --- a/src/cli/new.ts +++ b/src/cli/new.ts @@ -12,6 +12,7 @@ import { setupGit } from '../adapters/git.js'; import { setupAgentsMarkdown, setupClaudeMarkdown } from '../adapters/agents-md.js'; import { claudeCodeEnabled, installAllSkills, skillAgents } from '../adapters/skills.js'; import { initGraft } from '../adapters/graft.js'; +import { ignoreVendoredSkills } from '../adapters/tool-ignores.js'; import { agentBrowserConfigured, agentBrowserStubInstalled, @@ -160,6 +161,10 @@ export async function createProject( } else if (!silent) { logger.dim('Skills installation skipped (--no-skills or empty configuration)'); } + if ((options.skills !== false && config.skills.sources.length > 0) || agentBrowserWanted) { + const advice = ignoreVendoredSkills(targetDir, { dryRun, silent: true }); + if (!silent) for (const line of advice) logger.warn(line); + } // Provision the agent-browser runtime the skill stub drives if (agentBrowserWanted) { diff --git a/src/presets/web.ts b/src/presets/web.ts index 12d3bad..6fb2945 100644 --- a/src/presets/web.ts +++ b/src/presets/web.ts @@ -1,5 +1,5 @@ import path from 'node:path'; -import { fileExists, safeWriteFile, writeJsonFile } from '../utils/fs.js'; +import { fileExists, readTextFile, safeWriteFile, writeJsonFile } from '../utils/fs.js'; import { readPackageManifest } from '../config/validation.js'; import { runExecutable } from '../utils/shell.js'; import { PresetContext, PresetHandler } from './types.js'; @@ -8,6 +8,34 @@ import { setupAdr } from './adr.js'; import { scaffoldCommand } from '../adapters/package-manager.js'; import { addTestingDependencies, setupTesting } from './testing.js'; +/** Plain tsc cannot resolve framework virtual modules or single-file components, + * so those frameworks get their own checker (and the package that provides it). */ +function typecheckFor( + pkg: Record, + targetDir: string, + freshNext: boolean +): { script: string; checker?: [string, string] } { + const has = (name: string) => Boolean(pkg.dependencies?.[name] || pkg.devDependencies?.[name]); + if (freshNext) return { script: 'next typegen && tsc --noEmit' }; + if (has('astro')) return { script: 'astro check', checker: ['@astrojs/check', '^0.9.10'] }; + if (has('nuxt')) return { script: 'nuxi typecheck', checker: ['vue-tsc', '^3.3.13'] }; + if (has('@sveltejs/kit')) + return { + script: 'svelte-kit sync && svelte-check --tsconfig ./tsconfig.json', + checker: ['svelte-check', '^4.7.6'] + }; + if (has('svelte')) return { script: 'svelte-check', checker: ['svelte-check', '^4.7.6'] }; + // A solution-style tsconfig (Vite templates) has no files of its own: only build mode checks + // the referenced projects. + const build = /"references"\s*:/.test(readTextFile(path.join(targetDir, 'tsconfig.json')) || ''); + if (has('vue')) + return { + script: build ? 'vue-tsc -b --noEmit' : 'vue-tsc --noEmit', + checker: ['vue-tsc', '^3.3.13'] + }; + return { script: build ? 'tsc -b --noEmit' : 'tsc --noEmit' }; +} + export const webPreset: PresetHandler = { name: 'web', description: @@ -55,8 +83,12 @@ export const webPreset: PresetHandler = { ); addTestingDependencies(pkg, config.browser.playwright); if (fileExists(path.join(targetDir, 'tsconfig.json'))) { - pkg.scripts.typecheck ??= - fresh && framework === 'next' ? 'next typegen && tsc --noEmit' : 'tsc --noEmit'; + if (pkg.scripts.typecheck === undefined) { + const { script, checker } = typecheckFor(pkg, targetDir, fresh && framework === 'next'); + pkg.scripts.typecheck = script; + if (checker && !pkg.dependencies?.[checker[0]] && !pkg.devDependencies[checker[0]]) + pkg.devDependencies[checker[0]] = checker[1]; + } if (!pkg.dependencies?.typescript && !pkg.devDependencies.typescript) pkg.devDependencies.typescript = '^5.6.3'; } diff --git a/tests/presets.test.ts b/tests/presets.test.ts index cd52347..05f1b9f 100644 --- a/tests/presets.test.ts +++ b/tests/presets.test.ts @@ -70,6 +70,58 @@ describe('Preset Registry', () => { expect(fs.existsSync(path.join(npmDir, 'pnpm-workspace.yaml'))).toBe(false); }); + async function scaffoldExisting(pkg: Record, tsconfig: object = {}) { + fs.writeFileSync(path.join(tmpDir, 'package.json'), JSON.stringify(pkg)); + fs.writeFileSync(path.join(tmpDir, 'tsconfig.json'), JSON.stringify(tsconfig)); + await getPreset('web').scaffold({ + targetDir: tmpDir, + projectName: 'existing', + config: getDefaultConfig('existing', 'web', { packageManager: 'bun' }), + silent: true + }); + return JSON.parse(fs.readFileSync(path.join(tmpDir, 'package.json'), 'utf8')); + } + + it.each([ + [{ astro: '^5.0.0' }, 'astro check', '@astrojs/check'], + [{ nuxt: '^4.0.0' }, 'nuxi typecheck', 'vue-tsc'], + [ + { '@sveltejs/kit': '^2.0.0', svelte: '^5.0.0' }, + 'svelte-kit sync && svelte-check --tsconfig ./tsconfig.json', + 'svelte-check' + ], + [{ svelte: '^5.0.0' }, 'svelte-check', 'svelte-check'], + [{ vue: '^3.0.0' }, 'vue-tsc --noEmit', 'vue-tsc'] + ])('types an existing %o project with its framework checker', async (deps, script, checker) => { + const pkg = await scaffoldExisting({ name: 'existing', scripts: {}, dependencies: deps }); + expect(pkg.scripts.typecheck).toBe(script); + expect(pkg.devDependencies[checker]).toBeTruthy(); + }); + + it('keeps an existing typecheck script and the checker the project already pins', async () => { + const pkg = await scaffoldExisting({ + name: 'existing', + scripts: { typecheck: 'astro check --minimumSeverity warning' }, + dependencies: { astro: '^5.0.0', '@astrojs/check': '0.9.4' } + }); + expect(pkg.scripts.typecheck).toBe('astro check --minimumSeverity warning'); + expect(pkg.dependencies['@astrojs/check']).toBe('0.9.4'); + expect(pkg.devDependencies['@astrojs/check']).toBeUndefined(); + }); + + it('typechecks referenced projects of a solution-style tsconfig in build mode', async () => { + const pkg = await scaffoldExisting( + { name: 'existing', scripts: {}, dependencies: { react: '^19.0.0' } }, + { files: [], references: [{ path: './tsconfig.app.json' }] } + ); + expect(pkg.scripts.typecheck).toBe('tsc -b --noEmit'); + }); + + it('keeps plain tsc for projects without a framework checker', async () => { + const pkg = await scaffoldExisting({ name: 'existing', scripts: {} }); + expect(pkg.scripts.typecheck).toBe('tsc --noEmit'); + }); + it('generates pnpm-workspace.yaml only when packageManager is pnpm for cli preset', async () => { const cli = getPreset('cli'); diff --git a/tests/tool-ignores.test.ts b/tests/tool-ignores.test.ts new file mode 100644 index 0000000..3e4e603 --- /dev/null +++ b/tests/tool-ignores.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { ignoreVendoredSkills, vendoredSkillsExposure } from '../src/adapters/tool-ignores.js'; + +describe('ignoreVendoredSkills', () => { + let dir: string; + const write = (file: string, content: string) => fs.writeFileSync(path.join(dir, file), content); + const read = (file: string) => fs.readFileSync(path.join(dir, file), 'utf8'); + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'aginit-ignores-')); + }); + afterEach(() => fs.rmSync(dir, { recursive: true, force: true })); + + it('leaves a project without linters or formatters untouched', () => { + write('package.json', JSON.stringify({ name: 'plain' })); + expect(ignoreVendoredSkills(dir, { silent: true })).toEqual([]); + expect(fs.readdirSync(dir)).toEqual(['package.json']); + }); + + it('appends vendored dirs to .prettierignore once', () => { + write('package.json', JSON.stringify({ devDependencies: { prettier: '^3.0.0' } })); + write('.prettierignore', 'dist\n'); + ignoreVendoredSkills(dir, { silent: true }); + ignoreVendoredSkills(dir, { silent: true }); + expect(read('.prettierignore')).toBe( + 'dist\n\n# Vendored agent skills (aginit)\n.agents/\n.claude/\n' + ); + expect(vendoredSkillsExposure(dir)).toEqual([]); + }); + + it('writes .eslintignore for oxlint and legacy ESLint', () => { + write('package.json', JSON.stringify({ devDependencies: { oxlint: '^1.0.0' } })); + write('.eslintignore', '.agents/**\n'); + ignoreVendoredSkills(dir, { silent: true }); + expect(read('.eslintignore')).toBe( + '.agents/**\n\n# Vendored agent skills (aginit)\n.claude/\n' + ); + }); + + it('adds vendored dirs to a flat config ignores-only object (AstroPaper)', () => { + write( + 'eslint.config.js', + 'export default [\n ...base,\n { ignores: ["dist/**", ".astro"] },\n];\n' + ); + expect(ignoreVendoredSkills(dir, { silent: true })).toEqual([]); + expect(read('eslint.config.js')).toContain( + '{ ignores: [".agents/**", ".claude/**", "dist/**", ".astro"] }' + ); + expect(vendoredSkillsExposure(dir)).toEqual([]); + }); + + it('adds vendored dirs to globalIgnores (create-next-app)', () => { + write( + 'eslint.config.mjs', + 'const c = defineConfig([\n globalIgnores([\n // defaults\n ".next/**",\n ]),\n]);\n' + ); + ignoreVendoredSkills(dir, { silent: true }); + expect(read('eslint.config.mjs')).toContain( + 'globalIgnores([\n ".agents/**",\n ".claude/**",\n // defaults\n' + ); + }); + + it('advises instead of editing a flat config without a global ignores array', () => { + const config = "export default [{ files: ['**/*.js'], ignores: ['x'], rules: {} }];\n"; + write('eslint.config.js', config); + const advice = ignoreVendoredSkills(dir, { silent: true }); + expect(advice[0]).toContain("{ ignores: ['.agents/**', '.claude/**'] }"); + expect(read('eslint.config.js')).toBe(config); + expect(vendoredSkillsExposure(dir)).toHaveLength(1); + }); +}); From 6f4bc4219b38e189dd513cbfa92e30b710a58457 Mon Sep 17 00:00:00 2001 From: Antonio Mancini Date: Sun, 11 Oct 2026 14:00:30 +0200 Subject: [PATCH 2/2] refactor: describe each tool's vendored-skill ignores in one list, add Biome Each linter/formatter is one entry (where its ignores live, how to patch them, what to advise), so supporting another tool is one entry. Biome 2.2+ gets the folders force-ignored through files.includes. chore(release): 0.4.1 --- CHANGELOG.md | 9 ++ package.json | 2 +- src/adapters/tool-ignores.ts | 241 ++++++++++++++++++----------------- tests/tool-ignores.test.ts | 30 +++++ 4 files changed, 167 insertions(+), 115 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e8ec3a3..fd097e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 0.4.1 — 2026-10-11 + +### Fixed + +- `typecheck` on existing web projects uses the framework's checker instead of `tsc --noEmit`, which can't resolve Astro virtual modules or single-file components: `astro check` (adds `@astrojs/check`), `nuxi typecheck`, `svelte-kit sync && svelte-check` or `svelte-check`, and `vue-tsc` (each adds its checker package when missing). An existing `typecheck` script is left alone. +- Projects with a solution-style `tsconfig.json` (`"files": []` plus `references`, as in the Vite templates) get `tsc -b --noEmit`. Plain `tsc --noEmit` checked nothing there, so a fresh `--framework vite` project passed typecheck with type errors. +- Vendored skills no longer break the project's linters and formatters. `aginit init` and `aginit update` add `.agents/` and `.claude/` to `.prettierignore`, to `.eslintignore` (legacy ESLint, oxlint), to the global ignores of a flat ESLint config (`globalIgnores([...])` or an ignores-only object), and to `files.includes` in `biome.json` (Biome 2.2+). Configs that can't be edited safely get a warning with the exact line to add. +- `aginit doctor` reports linters and formatters that still check vendored skills. + ## 0.4.0 — 2026-10-11 ### Added diff --git a/package.json b/package.json index c14a546..b946a1e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@antomanc/aginit", - "version": "0.4.0", + "version": "0.4.1", "description": "Set up a new or existing project for AI coding agents in one command: AGENTS.md, Matt Pocock skills, Graft codebase graph, Vitest/Playwright. Orchestrates upstream tools, idempotent.", "type": "module", "main": "dist/index.js", diff --git a/src/adapters/tool-ignores.ts b/src/adapters/tool-ignores.ts index 56fdb0d..9add9cd 100644 --- a/src/adapters/tool-ignores.ts +++ b/src/adapters/tool-ignores.ts @@ -3,109 +3,136 @@ import { fileExists, readTextFile, safeWriteFile } from '../utils/fs.js'; import { readPackageManifest } from '../config/validation.js'; // Vendored skill sources ship third-party JS and Markdown that the project's own -// linters and formatters must not check. +// linters and formatters must not check. No ignore convention is shared across tools, +// so each tool below says where its ignores live and how to add to them. const VENDORED_DIRS = ['.agents', '.claude']; -const PRETTIER_CONFIGS = [ - '.prettierrc', - '.prettierrc.json', - '.prettierrc.json5', - '.prettierrc.yaml', - '.prettierrc.yml', - '.prettierrc.toml', - '.prettierrc.js', - '.prettierrc.cjs', - '.prettierrc.mjs', - '.prettierrc.ts', - '.prettierrc.cts', - '.prettierrc.mts', - 'prettier.config.js', - 'prettier.config.cjs', - 'prettier.config.mjs', - 'prettier.config.ts', - 'prettier.config.cts', - 'prettier.config.mts' -]; -const LEGACY_ESLINT_CONFIGS = [ - '.eslintrc', - '.eslintrc.js', - '.eslintrc.cjs', - '.eslintrc.json', - '.eslintrc.yaml', - '.eslintrc.yml' -]; -const FLAT_ESLINT_CONFIGS = [ - 'eslint.config.js', - 'eslint.config.mjs', - 'eslint.config.cjs', - 'eslint.config.ts', - 'eslint.config.mts', - 'eslint.config.cts' -]; - -interface IgnorePlan { - /** Ignore files (gitignore syntax) and the directory lines they lack. */ - ignoreFiles: Array<{ file: string; missing: string[] }>; - /** Flat ESLint config and the directories its ignores lack. */ - flatConfig?: { file: string; missing: string[] }; +interface Project { + manifest: Record | null; + has(dependency: string): boolean; + exists(file: string): boolean; } -function ignoreFileMissing(text: string): string[] { - const lines = text.split(/\r?\n/).map((line) => line.trim()); - return VENDORED_DIRS.filter( - (dir) => - !lines.some((line) => new RegExp(`^/?${dir.replace('.', '\\.')}(/(\\*\\*)?)?$`).test(line)) - ); +interface ToolIgnore { + /** The file holding this tool's ignores, when the project uses the tool. */ + file(project: Project): string | undefined; + /** Vendored directories the file does not ignore yet. */ + missing(text: string): string[]; + /** The file with those directories ignored, or null when it cannot be edited safely. */ + patch(text: string, dirs: string[]): string | null; + advice(file: string, dirs: string[]): string; } -function planIgnores(targetDir: string): IgnorePlan { - const pkg = readPackageManifest(targetDir); - const has = (name: string) => Boolean(pkg?.dependencies?.[name] || pkg?.devDependencies?.[name]); - const exists = (name: string) => fileExists(path.join(targetDir, name)); - const plan: IgnorePlan = { ignoreFiles: [] }; - const addIgnoreFile = (file: string) => { - const missing = ignoreFileMissing(readTextFile(path.join(targetDir, file)) || ''); - if (missing.length > 0) plan.ignoreFiles.push({ file, missing }); +const configs = (base: string, extensions: string[]) => extensions.map((ext) => `${base}${ext}`); + +/** Gitignore-syntax files: append the directories. */ +function ignoreFile(name: string, used: (project: Project) => boolean): ToolIgnore { + return { + file: (project) => (used(project) ? name : undefined), + missing: (text) => { + const lines = text.split(/\r?\n/).map((line) => line.trim().replace(/^\/|\/(\*\*)?$/g, '')); + return VENDORED_DIRS.filter((dir) => !lines.includes(dir)); + }, + patch: (text, dirs) => { + const block = `# Vendored agent skills (aginit)\n${dirs.map((dir) => `${dir}/`).join('\n')}\n`; + return text.trim() ? `${text.trimEnd()}\n\n${block}` : block; + }, + advice: (file, dirs) => `add ${dirs.map((dir) => `${dir}/`).join(' and ')} to ${file}` }; +} - if (has('prettier') || pkg?.prettier || PRETTIER_CONFIGS.some(exists)) - addIgnoreFile('.prettierignore'); - // Legacy ESLint and oxlint both read .eslintignore; flat-config ESLint does not. - if (has('oxlint') || pkg?.eslintConfig || LEGACY_ESLINT_CONFIGS.some(exists)) - addIgnoreFile('.eslintignore'); +const prettier = ignoreFile( + '.prettierignore', + (project) => + project.has('prettier') || + Boolean(project.manifest?.prettier) || + [ + ...configs('.prettierrc', ['', '.json', '.json5', '.yaml', '.yml', '.toml']), + ...configs('.prettierrc', ['.js', '.cjs', '.mjs', '.ts', '.cts', '.mts']), + ...configs('prettier.config', ['.js', '.cjs', '.mjs', '.ts', '.cts', '.mts']) + ].some(project.exists) +); - const flat = FLAT_ESLINT_CONFIGS.find(exists); - if (flat) { - const text = readTextFile(path.join(targetDir, flat)) || ''; - const missing = VENDORED_DIRS.filter((dir) => !text.includes(`${dir}/`)); - if (missing.length > 0) plan.flatConfig = { file: flat, missing }; - } - return plan; -} +// Legacy ESLint and oxlint read .eslintignore; flat-config ESLint does not. +const eslintIgnore = ignoreFile( + '.eslintignore', + (project) => + project.has('oxlint') || + Boolean(project.manifest?.eslintConfig) || + configs('.eslintrc', ['', '.js', '.cjs', '.json', '.yaml', '.yml']).some(project.exists) +); -/** Adds the vendored directories to a global ignores array, if the config has an - * unambiguous one: `globalIgnores([...])` or an object holding only `ignores`. */ -function patchFlatConfig(text: string, dirs: string[]): string | null { - const match = - /globalIgnores\(\s*\[/.exec(text) ?? /\{\s*ignores\s*:\s*\[(?=[^[\]]*\]\s*,?\s*\})/.exec(text); - if (!match) return null; - const at = match.index + match[0].length; - const rest = text.slice(at); - const quote = - /^\s*(['"`])/.exec(rest)?.[1] ?? - ((text.match(/"/g)?.length ?? 0) > (text.match(/'/g)?.length ?? 0) ? '"' : "'"); - const entries = dirs.map((dir) => `${quote}${dir}/**${quote}`); - const indent = /^(\s*\n[ \t]*)/.exec(rest)?.[1]; - const insertion = indent - ? entries.map((entry) => `${indent}${entry},`).join('') - : /^\s*\]/.test(rest) - ? entries.join(', ') - : entries.join(', ') + ', '; - return text.slice(0, at) + insertion + rest; -} +/** Flat ESLint config: extend a global ignores array if there is an unambiguous one, + * `globalIgnores([...])` or an object holding only `ignores`. */ +const eslintFlat: ToolIgnore = { + file: (project) => + configs('eslint.config', ['.js', '.mjs', '.cjs', '.ts', '.mts', '.cts']).find(project.exists), + missing: (text) => VENDORED_DIRS.filter((dir) => !text.includes(`${dir}/`)), + patch: (text, dirs) => { + const match = + /globalIgnores\(\s*\[/.exec(text) ?? + /\{\s*ignores\s*:\s*\[(?=[^[\]]*\]\s*,?\s*\})/.exec(text); + if (!match) return null; + const at = match.index + match[0].length; + const rest = text.slice(at); + const quote = + /^\s*(['"`])/.exec(rest)?.[1] ?? + ((text.match(/"/g)?.length ?? 0) > (text.match(/'/g)?.length ?? 0) ? '"' : "'"); + const entries = dirs.map((dir) => `${quote}${dir}/**${quote}`); + const indent = /^(\s*\n[ \t]*)/.exec(rest)?.[1]; + const insertion = indent + ? entries.map((entry) => `${indent}${entry},`).join('') + : entries.join(', ') + (/^\s*\]/.test(rest) ? '' : ', '); + return text.slice(0, at) + insertion + rest; + }, + advice: (file, dirs) => + `add { ignores: [${dirs.map((dir) => `'${dir}/**'`).join(', ')}] } to the exported array in ${file}` +}; + +/** Biome 2.2+: force-ignore the folders through files.includes. */ +const biome: ToolIgnore = { + file: (project) => ['biome.json', 'biome.jsonc'].find(project.exists), + missing: (text) => VENDORED_DIRS.filter((dir) => !text.includes(`**/${dir}`)), + patch: (text, dirs) => { + let config: Record; + try { + config = JSON.parse(text); // a biome.jsonc with comments gets advice instead + } catch { + return null; + } + const includes = config.files?.includes ?? ['**']; + if (!Array.isArray(includes)) return null; + config.files = { + ...config.files, + includes: [...includes, ...dirs.map((dir) => `!!**/${dir}`)] + }; + const json = JSON.stringify(config, null, /\n([ \t]+)"/.exec(text)?.[1] ?? 2) + '\n'; + // Like Biome's formatter, keep the array on one line when it fits in 80 columns. + return json.replace(/^([ \t]*"includes": )\[[^\]]*\]/m, (block, key: string) => { + const inline = `${key}[${config.files.includes.map((p: string) => JSON.stringify(p)).join(', ')}]`; + return inline.replace(/\t/g, ' ').length <= 80 ? inline : block; + }); + }, + advice: (file, dirs) => + `add ${dirs.map((dir) => `"!!**/${dir}"`).join(', ')} to files.includes in ${file}` +}; -function flatConfigAdvice(file: string, dirs: string[]): string { - return `add { ignores: [${dirs.map((dir) => `'${dir}/**'`).join(', ')}] } to the exported array in ${file}`; +const TOOLS = [prettier, eslintIgnore, eslintFlat, biome]; + +function pendingIgnores(targetDir: string) { + const manifest = readPackageManifest(targetDir); + const project: Project = { + manifest, + has: (name) => Boolean(manifest?.dependencies?.[name] || manifest?.devDependencies?.[name]), + exists: (file) => fileExists(path.join(targetDir, file)) + }; + return TOOLS.flatMap((tool) => { + const file = tool.file(project); + if (!file) return []; + const text = readTextFile(path.join(targetDir, file)) ?? ''; + const missing = tool.missing(text); + return missing.length > 0 ? [{ tool, file, text, missing }] : []; + }); } /** Keeps the project's linters and formatters off vendored skills. Returns the @@ -115,31 +142,17 @@ export function ignoreVendoredSkills( options: { dryRun?: boolean; silent?: boolean } = {} ): string[] { const { dryRun = false, silent = false } = options; - const plan = planIgnores(targetDir); - for (const { file, missing } of plan.ignoreFiles) { - const filePath = path.join(targetDir, file); - const existing = readTextFile(filePath); - const lines = missing.map((dir) => `${dir}/`).join('\n'); - const updated = existing - ? `${existing.trimEnd()}\n\n# Vendored agent skills (aginit)\n${lines}\n` - : `# Vendored agent skills (aginit)\n${lines}\n`; - safeWriteFile(filePath, updated, { overwrite: true, dryRun, silent }); + const advice: string[] = []; + for (const { tool, file, text, missing } of pendingIgnores(targetDir)) { + const patched = tool.patch(text, missing); + if (patched === null) + advice.push(`Linters check vendored skills: ${tool.advice(file, missing)}`); + else safeWriteFile(path.join(targetDir, file), patched, { overwrite: true, dryRun, silent }); } - if (!plan.flatConfig) return []; - const { file, missing } = plan.flatConfig; - const filePath = path.join(targetDir, file); - const patched = patchFlatConfig(readTextFile(filePath) || '', missing); - if (patched === null) return [`ESLint lints vendored skills: ${flatConfigAdvice(file, missing)}`]; - safeWriteFile(filePath, patched, { overwrite: true, dryRun, silent }); - return []; + return advice; } /** Doctor view: every config that still lets a linter or formatter see vendored skills. */ export function vendoredSkillsExposure(targetDir: string): string[] { - const plan = planIgnores(targetDir); - const issues = plan.ignoreFiles.map( - ({ file, missing }) => `add ${missing.map((dir) => `${dir}/`).join(' and ')} to ${file}` - ); - if (plan.flatConfig) issues.push(flatConfigAdvice(plan.flatConfig.file, plan.flatConfig.missing)); - return issues; + return pendingIgnores(targetDir).map(({ tool, file, missing }) => tool.advice(file, missing)); } diff --git a/tests/tool-ignores.test.ts b/tests/tool-ignores.test.ts index 3e4e603..eb56c9c 100644 --- a/tests/tool-ignores.test.ts +++ b/tests/tool-ignores.test.ts @@ -71,4 +71,34 @@ describe('ignoreVendoredSkills', () => { expect(read('eslint.config.js')).toBe(config); expect(vendoredSkillsExposure(dir)).toHaveLength(1); }); + it('force-ignores vendored dirs in biome.json, keeping its indentation', () => { + write( + 'biome.json', + '{\n\t"files": { "ignoreUnknown": false },\n\t"linter": { "enabled": true }\n}\n' + ); + expect(ignoreVendoredSkills(dir, { silent: true })).toEqual([]); + const config = JSON.parse(read('biome.json')); + expect(config.files.includes).toEqual(['**', '!!**/.agents', '!!**/.claude']); + expect(config.linter.enabled).toBe(true); + expect(read('biome.json')).toContain( + '\n\t\t"includes": ["**", "!!**/.agents", "!!**/.claude"]\n' + ); + expect(vendoredSkillsExposure(dir)).toEqual([]); + }); + + it('appends to existing biome includes and advises for a commented biome.jsonc', () => { + write('biome.json', JSON.stringify({ files: { includes: ['src/**'] } })); + ignoreVendoredSkills(dir, { silent: true }); + expect(JSON.parse(read('biome.json')).files.includes).toEqual([ + 'src/**', + '!!**/.agents', + '!!**/.claude' + ]); + + fs.rmSync(path.join(dir, 'biome.json')); + write('biome.jsonc', '{\n // comment\n "linter": {}\n}\n'); + expect(ignoreVendoredSkills(dir, { silent: true })[0]).toContain( + 'add "!!**/.agents", "!!**/.claude" to files.includes in biome.jsonc' + ); + }); });