diff --git a/README.md b/README.md index 9628d15..03d2684 100644 --- a/README.md +++ b/README.md @@ -2,15 +2,52 @@ > Lint CLAUDE.md and AI spec files — catches missing sections, secrets, and context bloat. -**Status:** Under construction +## Installation -## What this does +```bash +npm install -g @unityinflow/spec-linter +``` -spec-linter validates AI agent spec files (CLAUDE.md, GEMINI.md, GSD specs) for structural problems that silently degrade agent performance. +Or run directly: -## Installation +```bash +npx @unityinflow/spec-linter check CLAUDE.md +``` + +## Usage + +```bash +# Lint a specific file +spec-linter check CLAUDE.md + +# Lint all spec files in current directory +spec-linter check . + +# JSON output for CI +spec-linter check CLAUDE.md --format json + +# Only show errors +spec-linter check CLAUDE.md --quiet + +# List available rules +spec-linter rules +``` + +## Rules + +| ID | Name | Severity | Description | +|---|---|---|---| +| S001 | required-sections | error | Must have: Project Overview, Constraints, Acceptance Criteria | +| S003 | no-secrets | error | No API keys, tokens, or private keys | +| S004 | file-size | warning | >30kb warns, >50kb errors (context bloat) | +| S005 | no-wildcard-permissions | error | No `Bash(*:*)` or `"*"` in tool permissions | +| S006 | no-duplicate-headers | warning | No duplicate section headings at same level | + +## Exit Codes -Coming soon — `npm install -g @unityinflow/spec-linter` +- `0` — all checks passed +- `1` — errors found +- `2` — warnings only ## License diff --git a/package-lock.json b/package-lock.json index 737d699..2c1505e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ }, "devDependencies": { "@types/node": "^25.5.0", + "execa": "^9.6.1", "prettier": "^3.8.1", "tsup": "^8.5.1", "typescript": "^6.0.2", @@ -1184,6 +1185,26 @@ "win32" ] }, + "node_modules/@sec-ant/readable-stream": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", + "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sindresorhus/merge-streams": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", + "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -1465,6 +1486,21 @@ "dev": true, "license": "MIT" }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1552,6 +1588,33 @@ "@types/estree": "^1.0.0" } }, + "node_modules/execa": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz", + "integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^4.0.0", + "cross-spawn": "^7.0.6", + "figures": "^6.1.0", + "get-stream": "^9.0.0", + "human-signals": "^8.0.1", + "is-plain-obj": "^4.1.0", + "is-stream": "^4.0.1", + "npm-run-path": "^6.0.0", + "pretty-ms": "^9.2.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^4.0.0", + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": "^18.19.0 || >=20.5.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -1580,6 +1643,22 @@ } } }, + "node_modules/figures": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", + "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-unicode-supported": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/fix-dts-default-cjs-exports": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz", @@ -1607,6 +1686,79 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/get-stream": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", + "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sec-ant/readable-stream": "^0.4.1", + "is-stream": "^4.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/human-signals": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", + "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, "node_modules/joycon": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", @@ -1969,6 +2121,36 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/npm-run-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", + "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -1990,6 +2172,29 @@ ], "license": "MIT" }, + "node_modules/parse-ms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", + "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -2127,6 +2332,22 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/pretty-ms": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", + "integrity": "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse-ms": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -2230,6 +2451,29 @@ "fsevents": "~2.3.2" } }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -2237,6 +2481,19 @@ "dev": true, "license": "ISC" }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/source-map": { "version": "0.7.6", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", @@ -2271,6 +2528,19 @@ "dev": true, "license": "MIT" }, + "node_modules/strip-final-newline": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", + "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/sucrase": { "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", @@ -2474,6 +2744,19 @@ "dev": true, "license": "MIT" }, + "node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/vite": { "version": "8.0.3", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz", @@ -2644,6 +2927,22 @@ "node": ">=18" } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -2660,6 +2959,19 @@ "engines": { "node": ">=8" } + }, + "node_modules/yoctocolors": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", + "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/package.json b/package.json index 6bf83ab..4f2d268 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ }, "devDependencies": { "@types/node": "^25.5.0", + "execa": "^9.6.1", "prettier": "^3.8.1", "tsup": "^8.5.1", "typescript": "^6.0.2", diff --git a/src/formatters/json.ts b/src/formatters/json.ts new file mode 100644 index 0000000..0657170 --- /dev/null +++ b/src/formatters/json.ts @@ -0,0 +1,5 @@ +import { LintReport } from '../types.js'; + +export function formatJson(reports: LintReport[]): string { + return JSON.stringify(reports, null, 2); +} diff --git a/src/formatters/text.ts b/src/formatters/text.ts new file mode 100644 index 0000000..50670ad --- /dev/null +++ b/src/formatters/text.ts @@ -0,0 +1,28 @@ +import { LintReport } from '../types.js'; + +export function formatText(reports: LintReport[], quiet: boolean): string { + const lines: string[] = []; + + for (const report of reports) { + const filtered = quiet ? report.results.filter((r) => r.severity === 'error') : report.results; + if (filtered.length === 0) continue; + + lines.push(`\n${report.file}`); + for (const result of filtered) { + const lineInfo = result.line ? `:${result.line}` : ''; + const icon = result.severity === 'error' ? 'error' : 'warning'; + lines.push(` ${lineInfo} ${icon} ${result.message} (${result.ruleId})`); + } + } + + const totalErrors = reports.reduce((sum, r) => sum + r.errorCount, 0); + const totalWarnings = reports.reduce((sum, r) => sum + r.warningCount, 0); + + if (totalErrors === 0 && totalWarnings === 0) { + lines.push('All checks passed.'); + } else { + lines.push(`\n${totalErrors} error(s), ${totalWarnings} warning(s)`); + } + + return lines.join('\n'); +} diff --git a/src/index.ts b/src/index.ts index acb0152..081ff3d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,99 @@ -// Stub entry point — replaced with CLI in PR 4 -export { lint } from './engine.js'; +import { Command } from 'commander'; +import { readFileSync, statSync, readdirSync } from 'node:fs'; +import { resolve, join } from 'node:path'; +import { lint } from './engine.js'; +import { allRules } from './rules/index.js'; +import { formatText } from './formatters/text.js'; +import { formatJson } from './formatters/json.js'; +import { LintReport } from './types.js'; + +const SPEC_FILE_NAMES = [ + 'CLAUDE.md', + 'GEMINI.md', + 'AGENTS.md', + 'REQUIREMENTS.md', + 'PLAN.md', +]; + +function findSpecFiles(targetPath: string): string[] { + const resolved = resolve(targetPath); + const stat = statSync(resolved); + + if (stat.isFile()) { + return [resolved]; + } + + if (stat.isDirectory()) { + const files: string[] = []; + const entries = readdirSync(resolved); + for (const entry of entries) { + if (SPEC_FILE_NAMES.includes(entry)) { + const fullPath = join(resolved, entry); + const entryStat = statSync(fullPath); + if (entryStat.isFile()) { + files.push(fullPath); + } + } + } + return files; + } + + return []; +} + +const program = new Command(); + +program + .name('spec-linter') + .description('Lint CLAUDE.md and AI spec files') + .version('0.0.1'); + +program + .command('check') + .description('Lint spec files for common issues') + .argument('', 'File or directory to lint') + .option('--format ', 'Output format: text or json', 'text') + .option('--quiet', 'Only show errors, suppress warnings', false) + .action((targetPath: string, options: { format: string; quiet: boolean }) => { + const files = findSpecFiles(targetPath); + + if (files.length === 0) { + console.error(`No spec files found at: ${targetPath}`); + process.exit(1); + } + + const reports: LintReport[] = files.map((filePath) => { + const content = readFileSync(filePath, 'utf-8'); + return lint(filePath, content, allRules); + }); + + const output = + options.format === 'json' + ? formatJson(reports) + : formatText(reports, options.quiet); + + console.log(output); + + const hasErrors = reports.some((r) => r.errorCount > 0); + const hasWarnings = reports.some((r) => r.warningCount > 0); + + if (hasErrors) { + process.exit(1); + } else if (hasWarnings) { + process.exit(2); + } else { + process.exit(0); + } + }); + +program + .command('rules') + .description('List all available lint rules') + .action(() => { + for (const rule of allRules) { + const icon = rule.severity === 'error' ? 'ERR' : 'WRN'; + console.log(` ${rule.id} [${icon}] ${rule.name} — ${rule.description}`); + } + }); + +program.parse(); diff --git a/tests/cli.test.ts b/tests/cli.test.ts new file mode 100644 index 0000000..b5752a8 --- /dev/null +++ b/tests/cli.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { execaNode, execaCommand } from 'execa'; +import { resolve } from 'node:path'; + +const CLI_PATH = resolve('dist/index.js'); +const VALID_FIXTURE = resolve('tests/fixtures/valid-claude.md'); +const INVALID_FIXTURE = resolve('tests/fixtures/invalid-claude.md'); + +describe('CLI integration', () => { + beforeAll(async () => { + await execaCommand('npm run build'); + }); + + it('exits 0 for a valid file', async () => { + const result = await execaNode(CLI_PATH, ['check', VALID_FIXTURE]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('passed'); + }); + + it('exits 1 for a file with errors', async () => { + try { + await execaNode(CLI_PATH, ['check', INVALID_FIXTURE]); + expect.unreachable('Should have thrown'); + } catch (error: unknown) { + const execaError = error as { exitCode: number; stdout: string }; + expect(execaError.exitCode).toBe(1); + expect(execaError.stdout).toContain('error'); + } + }); + + it('outputs JSON with --format json', async () => { + const result = await execaNode(CLI_PATH, ['check', VALID_FIXTURE, '--format', 'json']); + const parsed = JSON.parse(result.stdout); + expect(Array.isArray(parsed)).toBe(true); + expect(parsed[0]).toHaveProperty('file'); + expect(parsed[0]).toHaveProperty('results'); + }); + + it('suppresses warnings with --quiet', async () => { + try { + await execaNode(CLI_PATH, ['check', INVALID_FIXTURE, '--quiet']); + } catch (error: unknown) { + const execaError = error as { stdout: string }; + // Individual warning lines (icon "warning") should be filtered out + expect(execaError.stdout).not.toContain('Duplicate heading'); + // Errors should still appear + expect(execaError.stdout).toContain('error'); + } + }); + + it('lists rules with the rules command', async () => { + const result = await execaNode(CLI_PATH, ['rules']); + expect(result.stdout).toContain('S001'); + expect(result.stdout).toContain('S003'); + expect(result.stdout).toContain('S004'); + expect(result.stdout).toContain('S005'); + expect(result.stdout).toContain('S006'); + }); +}); diff --git a/tests/formatters/json.test.ts b/tests/formatters/json.test.ts new file mode 100644 index 0000000..00c6860 --- /dev/null +++ b/tests/formatters/json.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from 'vitest'; +import { formatJson } from '../../src/formatters/json.js'; +import { LintReport } from '../../src/types.js'; + +describe('formatJson', () => { + it('returns valid JSON array', () => { + const reports: LintReport[] = [ + { file: 'CLAUDE.md', results: [], errorCount: 0, warningCount: 0 }, + ]; + const output = formatJson(reports); + const parsed = JSON.parse(output); + expect(Array.isArray(parsed)).toBe(true); + expect(parsed).toHaveLength(1); + }); + + it('includes all report fields', () => { + const reports: LintReport[] = [ + { + file: 'CLAUDE.md', + results: [ + { ruleId: 'S001', severity: 'error', message: 'Missing section', line: 1 }, + ], + errorCount: 1, + warningCount: 0, + }, + ]; + const output = formatJson(reports); + const parsed = JSON.parse(output); + expect(parsed[0]).toHaveProperty('file', 'CLAUDE.md'); + expect(parsed[0]).toHaveProperty('results'); + expect(parsed[0]).toHaveProperty('errorCount', 1); + expect(parsed[0]).toHaveProperty('warningCount', 0); + expect(parsed[0].results[0]).toHaveProperty('ruleId', 'S001'); + expect(parsed[0].results[0]).toHaveProperty('line', 1); + }); + + it('returns pretty-printed JSON', () => { + const reports: LintReport[] = [ + { file: 'CLAUDE.md', results: [], errorCount: 0, warningCount: 0 }, + ]; + const output = formatJson(reports); + expect(output).toContain('\n'); + expect(output).toContain(' '); + }); + + it('handles multiple reports', () => { + const reports: LintReport[] = [ + { file: 'CLAUDE.md', results: [], errorCount: 0, warningCount: 0 }, + { file: 'AGENTS.md', results: [], errorCount: 0, warningCount: 0 }, + ]; + const output = formatJson(reports); + const parsed = JSON.parse(output); + expect(parsed).toHaveLength(2); + }); +}); diff --git a/tests/formatters/text.test.ts b/tests/formatters/text.test.ts new file mode 100644 index 0000000..f8e5f6a --- /dev/null +++ b/tests/formatters/text.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect } from 'vitest'; +import { formatText } from '../../src/formatters/text.js'; +import { LintReport } from '../../src/types.js'; + +describe('formatText', () => { + it('shows "All checks passed." when no issues', () => { + const reports: LintReport[] = [ + { file: 'CLAUDE.md', results: [], errorCount: 0, warningCount: 0 }, + ]; + const output = formatText(reports, false); + expect(output).toContain('All checks passed.'); + }); + + it('displays errors with ruleId and line info', () => { + const reports: LintReport[] = [ + { + file: 'CLAUDE.md', + results: [ + { ruleId: 'S001', severity: 'error', message: 'Missing section: Project Overview', line: 1 }, + ], + errorCount: 1, + warningCount: 0, + }, + ]; + const output = formatText(reports, false); + expect(output).toContain('CLAUDE.md'); + expect(output).toContain(':1'); + expect(output).toContain('error'); + expect(output).toContain('S001'); + expect(output).toContain('1 error(s), 0 warning(s)'); + }); + + it('displays warnings alongside errors', () => { + const reports: LintReport[] = [ + { + file: 'CLAUDE.md', + results: [ + { ruleId: 'S001', severity: 'error', message: 'Missing section', line: 1 }, + { ruleId: 'S006', severity: 'warning', message: 'Duplicate header', line: 5 }, + ], + errorCount: 1, + warningCount: 1, + }, + ]; + const output = formatText(reports, false); + expect(output).toContain('error'); + expect(output).toContain('warning'); + expect(output).toContain('1 error(s), 1 warning(s)'); + }); + + it('suppresses warnings in quiet mode', () => { + const reports: LintReport[] = [ + { + file: 'CLAUDE.md', + results: [ + { ruleId: 'S001', severity: 'error', message: 'Missing section', line: 1 }, + { ruleId: 'S006', severity: 'warning', message: 'Duplicate header', line: 5 }, + ], + errorCount: 1, + warningCount: 1, + }, + ]; + const output = formatText(reports, true); + expect(output).toContain('Missing section'); + expect(output).not.toContain('Duplicate header'); + }); + + it('handles results without line numbers', () => { + const reports: LintReport[] = [ + { + file: 'CLAUDE.md', + results: [ + { ruleId: 'S004', severity: 'warning', message: 'File is large' }, + ], + errorCount: 0, + warningCount: 1, + }, + ]; + const output = formatText(reports, false); + expect(output).toContain('warning File is large (S004)'); + expect(output).not.toContain(':undefined'); + }); + + it('handles multiple files', () => { + const reports: LintReport[] = [ + { + file: 'CLAUDE.md', + results: [{ ruleId: 'S001', severity: 'error', message: 'Missing section' }], + errorCount: 1, + warningCount: 0, + }, + { + file: 'AGENTS.md', + results: [{ ruleId: 'S003', severity: 'error', message: 'Secret found' }], + errorCount: 1, + warningCount: 0, + }, + ]; + const output = formatText(reports, false); + expect(output).toContain('CLAUDE.md'); + expect(output).toContain('AGENTS.md'); + expect(output).toContain('2 error(s), 0 warning(s)'); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 7f4f508..1443c2f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "ignoreDeprecations": "6.0", "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext",