diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ccaf140..967f821 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,9 +10,9 @@ jobs: build-and-test: strategy: matrix: - os: [ubuntu-latest, macos-latest, windows-latest] + runner: [arc-runner-unityinflow, orangepi] node-version: [18, 20] - runs-on: ${{ matrix.os }} + runs-on: ${{ matrix.runner }} steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..7a7fa2b --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,46 @@ +# Contributing to spec-linter + +## Adding a New Rule + +1. Create `src/rules/SXXX-rule-name.ts` implementing the `Rule` interface +2. Create `tests/rules/SXXX.test.ts` with at least 3 passing and 3 failing cases +3. Register the rule in `src/rules/index.ts` +4. Update `README.md` rules table +5. Run `npm test` — all green +6. Submit a PR with the rule name in the title + +## Rule Interface + +```typescript +import { Rule, LintResult, ParsedSpecFile } from '../types.js'; + +export const myRule: Rule = { + id: 'SXXX', + name: 'rule-name', + severity: 'error', // or 'warning' + description: 'What this rule checks', + check(file: ParsedSpecFile): LintResult[] { + // Return array of issues found + return []; + }, +}; +``` + +## Development + +```bash +npm install # install deps +npm test # run tests +npm run test:watch # watch mode +npm run build # build to dist/ +npm run lint # type check +``` + +## Commit Convention + +``` +feat: add SXXX rule-name rule +fix: S001 false positive on nested headers +test: add edge cases for empty file input +docs: update README with SXXX examples +``` diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a944922 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Jiri Hermann / UnityInFlow + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 9628d15..34b0ad6 100644 --- a/README.md +++ b/README.md @@ -2,15 +2,95 @@ > Lint CLAUDE.md and AI spec files — catches missing sections, secrets, and context bloat. -**Status:** Under construction +## The Problem -## What this does +CLAUDE.md, GEMINI.md, and AI spec files are written by hand with zero validation. Common mistakes: -spec-linter validates AI agent spec files (CLAUDE.md, GEMINI.md, GSD specs) for structural problems that silently degrade agent performance. +- Missing required sections — the agent builds the wrong thing +- Accidentally committed API keys — security breach waiting to happen +- 80kb spec files — context window saturated, agent performance degrades + +These mistakes degrade agent performance **silently**. No error is thrown. The agent just produces subtly wrong output. + +## Examples of Errors Caught + +**Missing required sections:** +``` +$ spec-linter check my-project/CLAUDE.md + +my-project/CLAUDE.md + error Missing required section: "Project Overview" (S001) + error Missing required section: "Acceptance Criteria" (S001) + +2 error(s), 0 warning(s) +``` + +**Accidentally committed secret:** +``` +$ spec-linter check my-project/CLAUDE.md + +my-project/CLAUDE.md + :15 error Possible OpenAI/Anthropic API key detected. Remove before committing. (S003) + +1 error(s), 0 warning(s) +``` + +**Wildcard tool permissions:** +``` +$ spec-linter check my-project/CLAUDE.md + +my-project/CLAUDE.md + :42 error Wildcard tool permission (Tool(*:*)) — use explicit tool names instead of wildcards. (S005) + +1 error(s), 0 warning(s) +``` ## Installation -Coming soon — `npm install -g @unityinflow/spec-linter` +```bash +npm install -g @unityinflow/spec-linter +``` + +Or run directly: + +```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 + +- `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..b2ecb00 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@unityinflow/spec-linter", - "version": "0.0.0", + "version": "0.0.1", "description": "Lint CLAUDE.md and AI spec files — catches missing sections, secrets, and context bloat", "type": "module", "main": "./dist/index.js", @@ -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/engine.ts b/src/engine.ts index 09346e0..3cd8b47 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -1,14 +1,18 @@ -import { parseSpecFile } from './parser.js'; -import { LintReport, Rule } from './types.js'; +import { parseSpecFile } from "./parser.js"; +import { LintReport, Rule } from "./types.js"; -export function lint(filePath: string, content: string, rules: Rule[]): LintReport { +export function lint( + filePath: string, + content: string, + rules: Rule[], +): LintReport { const file = parseSpecFile(filePath, content); const results = rules.flatMap((rule) => rule.check(file)); return { file: filePath, results, - errorCount: results.filter((r) => r.severity === 'error').length, - warningCount: results.filter((r) => r.severity === 'warning').length, + errorCount: results.filter((r) => r.severity === "error").length, + warningCount: results.filter((r) => r.severity === "warning").length, }; } diff --git a/src/formatters/json.ts b/src/formatters/json.ts new file mode 100644 index 0000000..aaa2b15 --- /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..8ccb001 --- /dev/null +++ b/src/formatters/text.ts @@ -0,0 +1,32 @@ +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..ea9085f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,108 @@ -// 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); + + let stat; + try { + stat = statSync(resolved); + } catch { + console.error(`Path does not exist: ${resolved}`); + process.exit(1); + } + + 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/src/parser.ts b/src/parser.ts index 17ebd93..b9f3147 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -1,14 +1,14 @@ -import { ParsedSpecFile, Section } from './types.js'; +import { ParsedSpecFile, Section } from "./types.js"; export function parseSpecFile(path: string, raw: string): ParsedSpecFile { - const lines = raw.split('\n'); + const lines = raw.split("\n"); const sections: Section[] = []; let inCodeBlock = false; for (let index = 0; index < lines.length; index++) { const line = lines[index]; - if (line.startsWith('```')) { + if (line.startsWith("```")) { inCodeBlock = !inCodeBlock; continue; } @@ -21,10 +21,10 @@ export function parseSpecFile(path: string, raw: string): ParsedSpecFile { heading: headingMatch[2].trim(), level: headingMatch[1].length, line: index + 1, - content: '', + content: "", }); } else if (sections.length > 0) { - sections[sections.length - 1].content += line + '\n'; + sections[sections.length - 1].content += line + "\n"; } } @@ -36,6 +36,6 @@ export function parseSpecFile(path: string, raw: string): ParsedSpecFile { path, raw, sections, - sizeBytes: Buffer.byteLength(raw, 'utf-8'), + sizeBytes: Buffer.byteLength(raw, "utf-8"), }; } diff --git a/src/rules/S001-required-sections.ts b/src/rules/S001-required-sections.ts index b0302dc..5d9f770 100644 --- a/src/rules/S001-required-sections.ts +++ b/src/rules/S001-required-sections.ts @@ -1,25 +1,26 @@ -import { Rule, LintResult, ParsedSpecFile } from '../types.js'; +import { Rule, LintResult, ParsedSpecFile } from "../types.js"; const REQUIRED_SECTIONS = [ - 'Project Overview', - 'Constraints', - 'Acceptance Criteria', + "Project Overview", + "Constraints", + "Acceptance Criteria", ]; export const requiredSectionsRule: Rule = { - id: 'S001', - name: 'required-sections', - severity: 'error', - description: 'Spec files must contain required sections: Project Overview, Constraints, Acceptance Criteria', + id: "S001", + name: "required-sections", + severity: "error", + description: + "Spec files must contain required sections: Project Overview, Constraints, Acceptance Criteria", check(file: ParsedSpecFile): LintResult[] { const headings = file.sections.map((s) => s.heading.toLowerCase()); - return REQUIRED_SECTIONS - .filter((required) => !headings.includes(required.toLowerCase())) - .map((missing) => ({ - ruleId: 'S001', - severity: 'error' as const, - message: `Missing required section: "${missing}"`, - })); + return REQUIRED_SECTIONS.filter( + (required) => !headings.includes(required.toLowerCase()), + ).map((missing) => ({ + ruleId: "S001", + severity: "error" as const, + message: `Missing required section: "${missing}"`, + })); }, }; diff --git a/src/rules/S003-no-secrets.ts b/src/rules/S003-no-secrets.ts index ec7e236..6d861ab 100644 --- a/src/rules/S003-no-secrets.ts +++ b/src/rules/S003-no-secrets.ts @@ -1,32 +1,32 @@ -import { Rule, LintResult, ParsedSpecFile } from '../types.js'; +import { Rule, LintResult, ParsedSpecFile } from "../types.js"; const SECRET_PATTERNS: { pattern: RegExp; label: string }[] = [ - { pattern: /sk-[a-zA-Z0-9_-]{20,}/, label: 'OpenAI/Anthropic API key' }, - { pattern: /ghp_[a-zA-Z0-9]{36}/, label: 'GitHub personal access token' }, - { pattern: /gho_[a-zA-Z0-9]{36}/, label: 'GitHub OAuth token' }, - { pattern: /ghs_[a-zA-Z0-9]{36}/, label: 'GitHub server token' }, - { pattern: /AKIA[0-9A-Z]{16}/, label: 'AWS access key' }, - { pattern: /-----BEGIN\s+\w*\s*PRIVATE KEY-----/, label: 'Private key' }, - { pattern: /xoxb-[0-9]{10,}-[a-zA-Z0-9-]+/, label: 'Slack bot token' }, - { pattern: /xoxp-[0-9]{10,}-[a-zA-Z0-9-]+/, label: 'Slack user token' }, + { pattern: /sk-[a-zA-Z0-9_-]{20,}/, label: "OpenAI/Anthropic API key" }, + { pattern: /ghp_[a-zA-Z0-9]{36}/, label: "GitHub personal access token" }, + { pattern: /gho_[a-zA-Z0-9]{36}/, label: "GitHub OAuth token" }, + { pattern: /ghs_[a-zA-Z0-9]{36}/, label: "GitHub server token" }, + { pattern: /AKIA[0-9A-Z]{16}/, label: "AWS access key" }, + { pattern: /-----BEGIN\s+\w*\s*PRIVATE KEY-----/, label: "Private key" }, + { pattern: /xoxb-[0-9]{10,}-[a-zA-Z0-9-]+/, label: "Slack bot token" }, + { pattern: /xoxp-[0-9]{10,}-[a-zA-Z0-9-]+/, label: "Slack user token" }, ]; export const noSecretsRule: Rule = { - id: 'S003', - name: 'no-secrets', - severity: 'error', - description: 'Spec files must not contain secrets, API keys, or credentials', + id: "S003", + name: "no-secrets", + severity: "error", + description: "Spec files must not contain secrets, API keys, or credentials", check(file: ParsedSpecFile): LintResult[] { const results: LintResult[] = []; - const lines = file.raw.split('\n'); + const lines = file.raw.split("\n"); for (let i = 0; i < lines.length; i++) { const line = lines[i]; for (const { pattern, label } of SECRET_PATTERNS) { if (pattern.test(line)) { results.push({ - ruleId: 'S003', - severity: 'error', + ruleId: "S003", + severity: "error", message: `Possible ${label} detected. Remove before committing.`, line: i + 1, }); diff --git a/src/rules/S004-file-size.ts b/src/rules/S004-file-size.ts index 326c39e..51e5dc8 100644 --- a/src/rules/S004-file-size.ts +++ b/src/rules/S004-file-size.ts @@ -1,27 +1,31 @@ -import { Rule, LintResult, ParsedSpecFile } from '../types.js'; +import { Rule, LintResult, ParsedSpecFile } from "../types.js"; const WARN_THRESHOLD = 30 * 1024; const ERROR_THRESHOLD = 50 * 1024; export const fileSizeRule: Rule = { - id: 'S004', - name: 'file-size', - severity: 'warning', - description: 'Spec files over 30kb warn (context bloat), over 50kb error', + id: "S004", + name: "file-size", + severity: "warning", + description: "Spec files over 30kb warn (context bloat), over 50kb error", check(file: ParsedSpecFile): LintResult[] { if (file.sizeBytes > ERROR_THRESHOLD) { - return [{ - ruleId: 'S004', - severity: 'error', - message: `File is ${Math.round(file.sizeBytes / 1024)}kb — exceeds 50kb limit. Split into smaller files to avoid context bloat.`, - }]; + return [ + { + ruleId: "S004", + severity: "error", + message: `File is ${Math.round(file.sizeBytes / 1024)}kb — exceeds 50kb limit. Split into smaller files to avoid context bloat.`, + }, + ]; } if (file.sizeBytes > WARN_THRESHOLD) { - return [{ - ruleId: 'S004', - severity: 'warning', - message: `File is ${Math.round(file.sizeBytes / 1024)}kb — over 30kb warning threshold. Consider splitting.`, - }]; + return [ + { + ruleId: "S004", + severity: "warning", + message: `File is ${Math.round(file.sizeBytes / 1024)}kb — over 30kb warning threshold. Consider splitting.`, + }, + ]; } return []; }, diff --git a/src/rules/S005-no-wildcard-permissions.ts b/src/rules/S005-no-wildcard-permissions.ts index 7afec4b..8408e06 100644 --- a/src/rules/S005-no-wildcard-permissions.ts +++ b/src/rules/S005-no-wildcard-permissions.ts @@ -1,26 +1,30 @@ -import { Rule, LintResult, ParsedSpecFile } from '../types.js'; +import { Rule, LintResult, ParsedSpecFile } from "../types.js"; const WILDCARD_PATTERNS: { pattern: RegExp; label: string }[] = [ - { pattern: /\w+\(\*:\*\)/, label: 'Wildcard tool permission (Tool(*:*))' }, - { pattern: /"allow"\s*:\s*\[\s*"\*"\s*\]/, label: 'Wildcard allow list ("allow": ["*"])' }, - { pattern: /permissions\s*:\s*"\*"/, label: 'Wildcard permissions string' }, + { pattern: /\(\*:\*\)/, label: "Wildcard tool permission (Tool(*:*))" }, + { + pattern: /"allow"\s*:\s*\[\s*"\*"\s*\]/, + label: 'Wildcard allow list ("allow": ["*"])', + }, + { pattern: /permissions\s*:\s*"\*"/, label: "Wildcard permissions string" }, ]; export const noWildcardPermissionsRule: Rule = { - id: 'S005', - name: 'no-wildcard-permissions', - severity: 'error', - description: 'Tool permissions must not use wildcards — be explicit about allowed tools', + id: "S005", + name: "no-wildcard-permissions", + severity: "error", + description: + "Tool permissions must not use wildcards — be explicit about allowed tools", check(file: ParsedSpecFile): LintResult[] { const results: LintResult[] = []; - const lines = file.raw.split('\n'); + const lines = file.raw.split("\n"); for (let i = 0; i < lines.length; i++) { const line = lines[i]; for (const { pattern, label } of WILDCARD_PATTERNS) { if (pattern.test(line)) { results.push({ - ruleId: 'S005', - severity: 'error', + ruleId: "S005", + severity: "error", message: `${label} — use explicit tool names instead of wildcards.`, line: i + 1, }); diff --git a/src/rules/S006-no-duplicate-headers.ts b/src/rules/S006-no-duplicate-headers.ts index 9c6ce24..c28d5c1 100644 --- a/src/rules/S006-no-duplicate-headers.ts +++ b/src/rules/S006-no-duplicate-headers.ts @@ -1,10 +1,10 @@ -import { Rule, LintResult, ParsedSpecFile } from '../types.js'; +import { Rule, LintResult, ParsedSpecFile } from "../types.js"; export const noDuplicateHeadersRule: Rule = { - id: 'S006', - name: 'no-duplicate-headers', - severity: 'warning', - description: 'Duplicate section headings at the same level cause confusion', + id: "S006", + name: "no-duplicate-headers", + severity: "warning", + description: "Duplicate section headings at the same level cause confusion", check(file: ParsedSpecFile): LintResult[] { const results: LintResult[] = []; const seen = new Map(); @@ -12,8 +12,8 @@ export const noDuplicateHeadersRule: Rule = { const key = `${section.level}:${section.heading.toLowerCase()}`; if (seen.has(key)) { results.push({ - ruleId: 'S006', - severity: 'warning', + ruleId: "S006", + severity: "warning", message: `Duplicate heading "${section.heading}" (level ${section.level}) — first seen on line ${seen.get(key)}.`, line: section.line, }); diff --git a/src/rules/index.ts b/src/rules/index.ts index a9b7ed1..492e430 100644 --- a/src/rules/index.ts +++ b/src/rules/index.ts @@ -1,9 +1,9 @@ -import { Rule } from '../types.js'; -import { requiredSectionsRule } from './S001-required-sections.js'; -import { noSecretsRule } from './S003-no-secrets.js'; -import { fileSizeRule } from './S004-file-size.js'; -import { noWildcardPermissionsRule } from './S005-no-wildcard-permissions.js'; -import { noDuplicateHeadersRule } from './S006-no-duplicate-headers.js'; +import { Rule } from "../types.js"; +import { requiredSectionsRule } from "./S001-required-sections.js"; +import { noSecretsRule } from "./S003-no-secrets.js"; +import { fileSizeRule } from "./S004-file-size.js"; +import { noWildcardPermissionsRule } from "./S005-no-wildcard-permissions.js"; +import { noDuplicateHeadersRule } from "./S006-no-duplicate-headers.js"; export const allRules: Rule[] = [ requiredSectionsRule, diff --git a/src/types.ts b/src/types.ts index b57f4f8..3ff6a41 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,4 @@ -export type Severity = 'error' | 'warning'; +export type Severity = "error" | "warning"; export interface Section { heading: string; diff --git a/tests/cli.test.ts b/tests/cli.test.ts new file mode 100644 index 0000000..106cc78 --- /dev/null +++ b/tests/cli.test.ts @@ -0,0 +1,64 @@ +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/engine.test.ts b/tests/engine.test.ts index 3f85ae9..5acc3aa 100644 --- a/tests/engine.test.ts +++ b/tests/engine.test.ts @@ -1,52 +1,60 @@ -import { describe, it, expect } from 'vitest'; -import { lint } from '../src/engine.js'; +import { describe, it, expect } from "vitest"; +import { lint } from "../src/engine.js"; -describe('lint', () => { - it('returns a report with zero results for empty rules', () => { - const report = lint('/test.md', '## Project Overview\n\n## Constraints\n\n## Acceptance Criteria', []); - expect(report.file).toBe('/test.md'); +describe("lint", () => { + it("returns a report with zero results for empty rules", () => { + const report = lint( + "/test.md", + "## Project Overview\n\n## Constraints\n\n## Acceptance Criteria", + [], + ); + expect(report.file).toBe("/test.md"); expect(report.results).toHaveLength(0); expect(report.errorCount).toBe(0); expect(report.warningCount).toBe(0); }); - it('returns a report with file path', () => { - const report = lint('/path/to/CLAUDE.md', 'content', []); - expect(report.file).toBe('/path/to/CLAUDE.md'); + it("returns a report with file path", () => { + const report = lint("/path/to/CLAUDE.md", "content", []); + expect(report.file).toBe("/path/to/CLAUDE.md"); }); - it('aggregates results from multiple rules', () => { + it("aggregates results from multiple rules", () => { const fakeRule = { - id: 'T001', - name: 'test-rule', - severity: 'error' as const, - description: 'test', + id: "T001", + name: "test-rule", + severity: "error" as const, + description: "test", check: () => [ - { ruleId: 'T001', severity: 'error' as const, message: 'fail' }, + { ruleId: "T001", severity: "error" as const, message: "fail" }, ], }; - const report = lint('/test.md', 'content', [fakeRule]); + const report = lint("/test.md", "content", [fakeRule]); expect(report.results).toHaveLength(1); expect(report.errorCount).toBe(1); expect(report.warningCount).toBe(0); }); - it('counts errors and warnings separately', () => { + it("counts errors and warnings separately", () => { const errorRule = { - id: 'T001', - name: 'error-rule', - severity: 'error' as const, - description: 'test', - check: () => [{ ruleId: 'T001', severity: 'error' as const, message: 'err' }], + id: "T001", + name: "error-rule", + severity: "error" as const, + description: "test", + check: () => [ + { ruleId: "T001", severity: "error" as const, message: "err" }, + ], }; const warningRule = { - id: 'T002', - name: 'warning-rule', - severity: 'warning' as const, - description: 'test', - check: () => [{ ruleId: 'T002', severity: 'warning' as const, message: 'warn' }], + id: "T002", + name: "warning-rule", + severity: "warning" as const, + description: "test", + check: () => [ + { ruleId: "T002", severity: "warning" as const, message: "warn" }, + ], }; - const report = lint('/test.md', 'content', [errorRule, warningRule]); + const report = lint("/test.md", "content", [errorRule, warningRule]); expect(report.errorCount).toBe(1); expect(report.warningCount).toBe(1); expect(report.results).toHaveLength(2); diff --git a/tests/fixtures/invalid-claude.md b/tests/fixtures/invalid-claude.md index 6917202..295b17d 100644 --- a/tests/fixtures/invalid-claude.md +++ b/tests/fixtures/invalid-claude.md @@ -13,4 +13,4 @@ GITHUB_TOKEN=ghp_abcdefghijklmnopqrstuvwxyz1234567890 This is a duplicate heading. -Bash(*:*) +Bash(_:_) diff --git a/tests/formatters/json.test.ts b/tests/formatters/json.test.ts new file mode 100644 index 0000000..678ee47 --- /dev/null +++ b/tests/formatters/json.test.ts @@ -0,0 +1,60 @@ +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..49ff4ef --- /dev/null +++ b/tests/formatters/text.test.ts @@ -0,0 +1,133 @@ +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/tests/parser.test.ts b/tests/parser.test.ts index 442e4e8..b9f0267 100644 --- a/tests/parser.test.ts +++ b/tests/parser.test.ts @@ -1,48 +1,62 @@ -import { describe, it, expect } from 'vitest'; -import { parseSpecFile } from '../src/parser.js'; +import { describe, it, expect } from "vitest"; +import { parseSpecFile } from "../src/parser.js"; -describe('parseSpecFile', () => { - it('parses sections from markdown headings', () => { - const raw = '# Title\n\nSome content\n\n## Overview\n\nOverview text\n\n## Constraints\n\nConstraint text'; - const result = parseSpecFile('/test.md', raw); +describe("parseSpecFile", () => { + it("parses sections from markdown headings", () => { + const raw = + "# Title\n\nSome content\n\n## Overview\n\nOverview text\n\n## Constraints\n\nConstraint text"; + const result = parseSpecFile("/test.md", raw); expect(result.sections).toHaveLength(3); - expect(result.sections[0]).toMatchObject({ heading: 'Title', level: 1, line: 1 }); - expect(result.sections[1]).toMatchObject({ heading: 'Overview', level: 2, line: 5 }); - expect(result.sections[2]).toMatchObject({ heading: 'Constraints', level: 2, line: 9 }); + expect(result.sections[0]).toMatchObject({ + heading: "Title", + level: 1, + line: 1, + }); + expect(result.sections[1]).toMatchObject({ + heading: "Overview", + level: 2, + line: 5, + }); + expect(result.sections[2]).toMatchObject({ + heading: "Constraints", + level: 2, + line: 9, + }); }); - it('captures section content between headings', () => { - const raw = '## First\n\nFirst content\n\n## Second\n\nSecond content'; - const result = parseSpecFile('/test.md', raw); + it("captures section content between headings", () => { + const raw = "## First\n\nFirst content\n\n## Second\n\nSecond content"; + const result = parseSpecFile("/test.md", raw); - expect(result.sections[0].content).toContain('First content'); - expect(result.sections[1].content).toContain('Second content'); + expect(result.sections[0].content).toContain("First content"); + expect(result.sections[1].content).toContain("Second content"); }); - it('returns empty sections for empty input', () => { - const result = parseSpecFile('/empty.md', ''); + it("returns empty sections for empty input", () => { + const result = parseSpecFile("/empty.md", ""); expect(result.sections).toHaveLength(0); expect(result.sizeBytes).toBe(0); }); - it('handles file with no headings', () => { - const raw = 'Just some text without any headings'; - const result = parseSpecFile('/no-headings.md', raw); + it("handles file with no headings", () => { + const raw = "Just some text without any headings"; + const result = parseSpecFile("/no-headings.md", raw); expect(result.sections).toHaveLength(0); }); - it('calculates sizeBytes correctly', () => { - const raw = 'Hello world'; - const result = parseSpecFile('/test.md', raw); - expect(result.sizeBytes).toBe(Buffer.byteLength(raw, 'utf-8')); + it("calculates sizeBytes correctly", () => { + const raw = "Hello world"; + const result = parseSpecFile("/test.md", raw); + expect(result.sizeBytes).toBe(Buffer.byteLength(raw, "utf-8")); }); - it('handles headings inside code blocks by ignoring them', () => { - const raw = '## Real Heading\n\n```\n## Not A Heading\n```\n\n## Another Real Heading'; - const result = parseSpecFile('/test.md', raw); + it("handles headings inside code blocks by ignoring them", () => { + const raw = + "## Real Heading\n\n```\n## Not A Heading\n```\n\n## Another Real Heading"; + const result = parseSpecFile("/test.md", raw); expect(result.sections).toHaveLength(2); - expect(result.sections[0].heading).toBe('Real Heading'); - expect(result.sections[1].heading).toBe('Another Real Heading'); + expect(result.sections[0].heading).toBe("Real Heading"); + expect(result.sections[1].heading).toBe("Another Real Heading"); }); }); diff --git a/tests/rules/S001.test.ts b/tests/rules/S001.test.ts index 65dce63..9e6e13a 100644 --- a/tests/rules/S001.test.ts +++ b/tests/rules/S001.test.ts @@ -1,65 +1,73 @@ -import { describe, it, expect } from 'vitest'; -import { requiredSectionsRule } from '../../src/rules/S001-required-sections.js'; -import { parseSpecFile } from '../../src/parser.js'; +import { describe, it, expect } from "vitest"; +import { requiredSectionsRule } from "../../src/rules/S001-required-sections.js"; +import { parseSpecFile } from "../../src/parser.js"; -describe('S001 required-sections', () => { +describe("S001 required-sections", () => { const check = (content: string) => { - const file = parseSpecFile('/test.md', content); + const file = parseSpecFile("/test.md", content); return requiredSectionsRule.check(file); }; - it('passes when all required sections are present', () => { - const content = '## Project Overview\n\nOverview\n\n## Constraints\n\nConstraints\n\n## Acceptance Criteria\n\nCriteria'; + it("passes when all required sections are present", () => { + const content = + "## Project Overview\n\nOverview\n\n## Constraints\n\nConstraints\n\n## Acceptance Criteria\n\nCriteria"; expect(check(content)).toHaveLength(0); }); - it('passes with different heading levels for required sections', () => { - const content = '# Project Overview\n\n## Constraints\n\n### Acceptance Criteria'; + it("passes with different heading levels for required sections", () => { + const content = + "# Project Overview\n\n## Constraints\n\n### Acceptance Criteria"; expect(check(content)).toHaveLength(0); }); - it('passes when required sections exist among other sections', () => { - const content = '## Intro\n\n## Project Overview\n\n## Stack\n\n## Constraints\n\n## Notes\n\n## Acceptance Criteria'; + it("passes when required sections exist among other sections", () => { + const content = + "## Intro\n\n## Project Overview\n\n## Stack\n\n## Constraints\n\n## Notes\n\n## Acceptance Criteria"; expect(check(content)).toHaveLength(0); }); - it('fails when Project Overview is missing', () => { - const content = '## Constraints\n\n## Acceptance Criteria'; + it("fails when Project Overview is missing", () => { + const content = "## Constraints\n\n## Acceptance Criteria"; const results = check(content); expect(results.length).toBeGreaterThanOrEqual(1); - expect(results.some((r) => r.message.includes('Project Overview'))).toBe(true); + expect(results.some((r) => r.message.includes("Project Overview"))).toBe( + true, + ); }); - it('fails when Constraints is missing', () => { - const content = '## Project Overview\n\n## Acceptance Criteria'; + it("fails when Constraints is missing", () => { + const content = "## Project Overview\n\n## Acceptance Criteria"; const results = check(content); - expect(results.some((r) => r.message.includes('Constraints'))).toBe(true); + expect(results.some((r) => r.message.includes("Constraints"))).toBe(true); }); - it('fails when Acceptance Criteria is missing', () => { - const content = '## Project Overview\n\n## Constraints'; + it("fails when Acceptance Criteria is missing", () => { + const content = "## Project Overview\n\n## Constraints"; const results = check(content); - expect(results.some((r) => r.message.includes('Acceptance Criteria'))).toBe(true); + expect(results.some((r) => r.message.includes("Acceptance Criteria"))).toBe( + true, + ); }); - it('fails when all required sections are missing', () => { - const content = '## Random Section\n\nSome text'; + it("fails when all required sections are missing", () => { + const content = "## Random Section\n\nSome text"; const results = check(content); expect(results).toHaveLength(3); }); - it('fails on empty file', () => { - const results = check(''); + it("fails on empty file", () => { + const results = check(""); expect(results).toHaveLength(3); }); - it('is case-insensitive for section matching', () => { - const content = '## project overview\n\n## constraints\n\n## acceptance criteria'; + it("is case-insensitive for section matching", () => { + const content = + "## project overview\n\n## constraints\n\n## acceptance criteria"; expect(check(content)).toHaveLength(0); }); - it('has correct rule metadata', () => { - expect(requiredSectionsRule.id).toBe('S001'); - expect(requiredSectionsRule.severity).toBe('error'); + it("has correct rule metadata", () => { + expect(requiredSectionsRule.id).toBe("S001"); + expect(requiredSectionsRule.severity).toBe("error"); }); }); diff --git a/tests/rules/S003.test.ts b/tests/rules/S003.test.ts index 9ebdd6d..2d1a4a6 100644 --- a/tests/rules/S003.test.ts +++ b/tests/rules/S003.test.ts @@ -1,74 +1,81 @@ -import { describe, it, expect } from 'vitest'; -import { noSecretsRule } from '../../src/rules/S003-no-secrets.js'; -import { parseSpecFile } from '../../src/parser.js'; +import { describe, it, expect } from "vitest"; +import { noSecretsRule } from "../../src/rules/S003-no-secrets.js"; +import { parseSpecFile } from "../../src/parser.js"; -describe('S003 no-secrets', () => { +describe("S003 no-secrets", () => { const check = (content: string) => { - const file = parseSpecFile('/test.md', content); + const file = parseSpecFile("/test.md", content); return noSecretsRule.check(file); }; - it('passes a file with no credentials', () => { - const content = '## Project Overview\n\nThis is a normal spec file with no secrets.'; + it("passes a file with no credentials", () => { + const content = + "## Project Overview\n\nThis is a normal spec file with no secrets."; expect(check(content)).toHaveLength(0); }); - it('passes a file that mentions key patterns in prose without actual keys', () => { - const content = '## Constraints\n\nDo not commit API keys. Use environment variables for sk- prefixed tokens.'; + it("passes a file that mentions key patterns in prose without actual keys", () => { + const content = + "## Constraints\n\nDo not commit API keys. Use environment variables for sk- prefixed tokens."; expect(check(content)).toHaveLength(0); }); - it('passes a file with short strings that look like prefixes but are not keys', () => { - const content = '## Notes\n\nThe sk-prefix is used by OpenAI.'; + it("passes a file with short strings that look like prefixes but are not keys", () => { + const content = "## Notes\n\nThe sk-prefix is used by OpenAI."; expect(check(content)).toHaveLength(0); }); - it('catches an OpenAI API key', () => { - const content = '## Config\n\nOPENAI_API_KEY=sk-1234567890abcdefghij1234567890abcdefghij12345678'; + it("catches an OpenAI API key", () => { + const content = + "## Config\n\nOPENAI_API_KEY=sk-1234567890abcdefghij1234567890abcdefghij12345678"; const results = check(content); expect(results.length).toBeGreaterThanOrEqual(1); - expect(results[0].severity).toBe('error'); - expect(results[0].ruleId).toBe('S003'); + expect(results[0].severity).toBe("error"); + expect(results[0].ruleId).toBe("S003"); }); - it('catches a GitHub personal access token', () => { - const content = '## Auth\n\ntoken: ghp_abcdefghijklmnopqrstuvwxyz1234567890'; + it("catches a GitHub personal access token", () => { + const content = + "## Auth\n\ntoken: ghp_abcdefghijklmnopqrstuvwxyz1234567890"; const results = check(content); expect(results.length).toBeGreaterThanOrEqual(1); }); - it('catches an AWS access key', () => { - const content = '## Infra\n\nAWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE'; + it("catches an AWS access key", () => { + const content = "## Infra\n\nAWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE"; const results = check(content); expect(results.length).toBeGreaterThanOrEqual(1); }); - it('catches a private key block', () => { - const content = '## Keys\n\n-----BEGIN RSA PRIVATE KEY-----\nMIIBogIBAAJ'; + it("catches a private key block", () => { + const content = "## Keys\n\n-----BEGIN RSA PRIVATE KEY-----\nMIIBogIBAAJ"; const results = check(content); expect(results.length).toBeGreaterThanOrEqual(1); }); - it('catches an Anthropic API key pattern', () => { - const content = '## Config\n\nsk-ant-api03-abcdefghijklmnopqrstuvwxyz123456'; + it("catches an Anthropic API key pattern", () => { + const content = + "## Config\n\nsk-ant-api03-abcdefghijklmnopqrstuvwxyz123456"; const results = check(content); expect(results.length).toBeGreaterThanOrEqual(1); }); - it('reports the correct line number', () => { - const content = 'Line 1\nLine 2\nsk-1234567890abcdefghij1234567890abcdefghij12345678\nLine 4'; + it("reports the correct line number", () => { + const content = + "Line 1\nLine 2\nsk-1234567890abcdefghij1234567890abcdefghij12345678\nLine 4"; const results = check(content); expect(results[0].line).toBe(3); }); - it('catches multiple secrets on different lines', () => { - const content = 'sk-1234567890abcdefghij1234567890abcdefghij12345678\n\nghp_abcdefghijklmnopqrstuvwxyz1234567890'; + it("catches multiple secrets on different lines", () => { + const content = + "sk-1234567890abcdefghij1234567890abcdefghij12345678\n\nghp_abcdefghijklmnopqrstuvwxyz1234567890"; const results = check(content); expect(results.length).toBeGreaterThanOrEqual(2); }); - it('has correct rule metadata', () => { - expect(noSecretsRule.id).toBe('S003'); - expect(noSecretsRule.severity).toBe('error'); + it("has correct rule metadata", () => { + expect(noSecretsRule.id).toBe("S003"); + expect(noSecretsRule.severity).toBe("error"); }); }); diff --git a/tests/rules/S004.test.ts b/tests/rules/S004.test.ts index 989f32c..69f17f6 100644 --- a/tests/rules/S004.test.ts +++ b/tests/rules/S004.test.ts @@ -1,50 +1,50 @@ -import { describe, it, expect } from 'vitest'; -import { fileSizeRule } from '../../src/rules/S004-file-size.js'; -import { parseSpecFile } from '../../src/parser.js'; +import { describe, it, expect } from "vitest"; +import { fileSizeRule } from "../../src/rules/S004-file-size.js"; +import { parseSpecFile } from "../../src/parser.js"; -describe('S004 file-size', () => { +describe("S004 file-size", () => { const check = (content: string) => { - const file = parseSpecFile('/test.md', content); + const file = parseSpecFile("/test.md", content); return fileSizeRule.check(file); }; - it('passes a small file', () => { - expect(check('Small file content')).toHaveLength(0); + it("passes a small file", () => { + expect(check("Small file content")).toHaveLength(0); }); - it('passes a file just under 30kb', () => { - const content = 'x'.repeat(29 * 1024); + it("passes a file just under 30kb", () => { + const content = "x".repeat(29 * 1024); expect(check(content)).toHaveLength(0); }); - it('warns for a file over 30kb', () => { - const content = 'x'.repeat(31 * 1024); + it("warns for a file over 30kb", () => { + const content = "x".repeat(31 * 1024); const results = check(content); expect(results).toHaveLength(1); - expect(results[0].severity).toBe('warning'); - expect(results[0].ruleId).toBe('S004'); + expect(results[0].severity).toBe("warning"); + expect(results[0].ruleId).toBe("S004"); }); - it('errors for a file over 50kb', () => { - const content = 'x'.repeat(51 * 1024); + it("errors for a file over 50kb", () => { + const content = "x".repeat(51 * 1024); const results = check(content); expect(results).toHaveLength(1); - expect(results[0].severity).toBe('error'); + expect(results[0].severity).toBe("error"); }); - it('errors at exactly the 50kb boundary', () => { - const content = 'x'.repeat(50 * 1024 + 1); + it("errors at exactly the 50kb boundary", () => { + const content = "x".repeat(50 * 1024 + 1); const results = check(content); expect(results).toHaveLength(1); - expect(results[0].severity).toBe('error'); + expect(results[0].severity).toBe("error"); }); - it('passes an empty file', () => { - expect(check('')).toHaveLength(0); + it("passes an empty file", () => { + expect(check("")).toHaveLength(0); }); - it('has correct rule metadata', () => { - expect(fileSizeRule.id).toBe('S004'); - expect(fileSizeRule.name).toBe('file-size'); + it("has correct rule metadata", () => { + expect(fileSizeRule.id).toBe("S004"); + expect(fileSizeRule.name).toBe("file-size"); }); }); diff --git a/tests/rules/S005.test.ts b/tests/rules/S005.test.ts index 066e1ae..ba895b5 100644 --- a/tests/rules/S005.test.ts +++ b/tests/rules/S005.test.ts @@ -1,32 +1,32 @@ -import { describe, it, expect } from 'vitest'; -import { noWildcardPermissionsRule } from '../../src/rules/S005-no-wildcard-permissions.js'; -import { parseSpecFile } from '../../src/parser.js'; +import { describe, it, expect } from "vitest"; +import { noWildcardPermissionsRule } from "../../src/rules/S005-no-wildcard-permissions.js"; +import { parseSpecFile } from "../../src/parser.js"; -describe('S005 no-wildcard-permissions', () => { +describe("S005 no-wildcard-permissions", () => { const check = (content: string) => { - const file = parseSpecFile('/test.md', content); + const file = parseSpecFile("/test.md", content); return noWildcardPermissionsRule.check(file); }; - it('passes a file with no permission blocks', () => { - expect(check('## Overview\n\nJust a normal spec.')).toHaveLength(0); + it("passes a file with no permission blocks", () => { + expect(check("## Overview\n\nJust a normal spec.")).toHaveLength(0); }); - it('passes specific tool permissions', () => { - const content = '## Permissions\n\nBash(git:*)\nRead(src/*)'; + it("passes specific tool permissions", () => { + const content = "## Permissions\n\nBash(git:*)\nRead(src/*)"; expect(check(content)).toHaveLength(0); }); - it('passes a file with no wildcards', () => { + it("passes a file with no wildcards", () => { const content = '## Config\n\nallow: ["Read", "Write"]'; expect(check(content)).toHaveLength(0); }); - it('catches Bash(*:*) wildcard', () => { - const content = '## Permissions\n\nBash(*:*)'; + it("catches Bash(*:*) wildcard", () => { + const content = "## Permissions\n\nBash(*:*)"; const results = check(content); expect(results.length).toBeGreaterThanOrEqual(1); - expect(results[0].severity).toBe('error'); + expect(results[0].severity).toBe("error"); }); it('catches "allow": ["*"] in JSON-like blocks', () => { @@ -41,20 +41,20 @@ describe('S005 no-wildcard-permissions', () => { expect(results.length).toBeGreaterThanOrEqual(1); }); - it('reports the correct line number', () => { - const content = 'Line 1\nLine 2\nBash(*:*)\nLine 4'; + it("reports the correct line number", () => { + const content = "Line 1\nLine 2\nBash(*:*)\nLine 4"; const results = check(content); expect(results[0].line).toBe(3); }); - it('catches multiple wildcards', () => { + it("catches multiple wildcards", () => { const content = 'Bash(*:*)\n\n"allow": ["*"]'; const results = check(content); expect(results.length).toBeGreaterThanOrEqual(2); }); - it('has correct rule metadata', () => { - expect(noWildcardPermissionsRule.id).toBe('S005'); - expect(noWildcardPermissionsRule.severity).toBe('error'); + it("has correct rule metadata", () => { + expect(noWildcardPermissionsRule.id).toBe("S005"); + expect(noWildcardPermissionsRule.severity).toBe("error"); }); }); diff --git a/tests/rules/S006.test.ts b/tests/rules/S006.test.ts index 057aa17..5755fc4 100644 --- a/tests/rules/S006.test.ts +++ b/tests/rules/S006.test.ts @@ -1,54 +1,54 @@ -import { describe, it, expect } from 'vitest'; -import { noDuplicateHeadersRule } from '../../src/rules/S006-no-duplicate-headers.js'; -import { parseSpecFile } from '../../src/parser.js'; +import { describe, it, expect } from "vitest"; +import { noDuplicateHeadersRule } from "../../src/rules/S006-no-duplicate-headers.js"; +import { parseSpecFile } from "../../src/parser.js"; -describe('S006 no-duplicate-headers', () => { +describe("S006 no-duplicate-headers", () => { const check = (content: string) => { - const file = parseSpecFile('/test.md', content); + const file = parseSpecFile("/test.md", content); return noDuplicateHeadersRule.check(file); }; - it('passes a file with unique headings', () => { - const content = '## Overview\n\n## Constraints\n\n## Criteria'; + it("passes a file with unique headings", () => { + const content = "## Overview\n\n## Constraints\n\n## Criteria"; expect(check(content)).toHaveLength(0); }); - it('passes an empty file', () => { - expect(check('')).toHaveLength(0); + it("passes an empty file", () => { + expect(check("")).toHaveLength(0); }); - it('passes headings at different levels with same text', () => { - const content = '# Overview\n\n## Overview'; + it("passes headings at different levels with same text", () => { + const content = "# Overview\n\n## Overview"; expect(check(content)).toHaveLength(0); }); - it('catches duplicate ## headings', () => { - const content = '## Overview\n\nFirst\n\n## Overview\n\nSecond'; + it("catches duplicate ## headings", () => { + const content = "## Overview\n\nFirst\n\n## Overview\n\nSecond"; const results = check(content); expect(results).toHaveLength(1); - expect(results[0].severity).toBe('warning'); + expect(results[0].severity).toBe("warning"); }); - it('reports the line of the duplicate, not the original', () => { - const content = '## Overview\n\nFirst\n\n## Overview\n\nSecond'; + it("reports the line of the duplicate, not the original", () => { + const content = "## Overview\n\nFirst\n\n## Overview\n\nSecond"; const results = check(content); expect(results[0].line).toBe(5); }); - it('catches multiple sets of duplicates', () => { - const content = '## A\n\n## B\n\n## A\n\n## B'; + it("catches multiple sets of duplicates", () => { + const content = "## A\n\n## B\n\n## A\n\n## B"; const results = check(content); expect(results).toHaveLength(2); }); - it('is case-insensitive', () => { - const content = '## Overview\n\n## overview'; + it("is case-insensitive", () => { + const content = "## Overview\n\n## overview"; const results = check(content); expect(results).toHaveLength(1); }); - it('has correct rule metadata', () => { - expect(noDuplicateHeadersRule.id).toBe('S006'); - expect(noDuplicateHeadersRule.severity).toBe('warning'); + it("has correct rule metadata", () => { + expect(noDuplicateHeadersRule.id).toBe("S006"); + expect(noDuplicateHeadersRule.severity).toBe("warning"); }); }); 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",