diff --git a/.humancov b/.humancov index 0de90ad..407d850 100644 --- a/.humancov +++ b/.humancov @@ -14,6 +14,7 @@ test/badge.test.js ai false false claude-code - test/cli.test.js ai false false claude-code - test/comments.test.js ai false false claude-code - test/ignore.test.js ai false false claude-code - +test/init.test.js ai false false claude-code - test/manifest.test.js ai false false claude-code - test/parser.test.js ai false false claude-code - test/report.test.js ai false false claude-code - diff --git a/CLAUDE.md b/CLAUDE.md index dafd289..c0cd29d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,3 +1,76 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Build and Run + +```bash +npm install # install deps (single dep: `ignore`) +npm test # run all tests (node:test, no framework) +node --test test/parser.test.js # run a single test file +node bench/perf.js # run benchmarks +npx humancov scan # run the CLI locally +npm run ci # run CI locally via `act` (requires act installed) +``` + +No build step - pure ESM (`"type": "module"` in package.json), runs directly with Node >= 18. + +## Architecture + +The CLI entry point is `bin/humancov.js` - a thin dispatcher that parses args and delegates to modules in `src/`: + +- `scanner.js` - core scan logic. Lists files via `git ls-files` (falls back to recursive walk), filters through ignore patterns, calls parser on each file, aggregates summary stats. +- `parser.js` - reads first 20 lines of a file, extracts `AI-Provenance-*` headers using comment-syntax-aware parsing. +- `comments.js` - maps file extensions to comment prefixes (`//`, `#`, ` -``` -```css -/* AI-Provenance-Origin: ai */ /* CSS, SCSS, Less */ -``` -```sql --- AI-Provenance-Origin: ai -- SQL, Lua -``` +| Key | Required | Values | +|---|---|---| +| `Origin` | yes | `ai`, `human`, `mixed` | +| `Generator` | no | tool name (`claude-code`, `copilot`, `codex`...) | +| `Reviewed` | yes | `true`, `false`, `partial` | +| `Tested` | no | `true`, `false`, `partial` | +| `Confidence` | no | `high`, `medium`, `low` | +| `Notes` | no | free-text | --- ## Ignore Files -Create `.humancov-ignore` at repo root (gitignore syntax) to exclude files from scanning: +humancov automatically respects your **`.gitignore`** - no extra setup needed. Anything ignored by git is ignored by humancov. + +For humancov-specific ignores on top of `.gitignore`, create `.humancov-ignore` at repo root (same gitignore syntax): ```gitignore *.md @@ -153,7 +162,9 @@ dist/ coverage/ ``` -Default ignores: `node_modules/`, `.git/`, `.humancov`, `.humancov-ignore`, `*.md`, `*.lock`, `LICENSE`, `.gitignore`, `.github/`. +**Resolution order:** built-in defaults → `.gitignore` → `.humancov-ignore`. + +Built-in defaults: `node_modules/`, `.git/`, `.humancov`, `.humancov-ignore`, `*.md`, `*.lock`, `LICENSE`, `.gitignore`, `.github/`. --- @@ -192,11 +203,10 @@ Exits with code 1 if the reviewed percentage is below the threshold. A pre-commit hook keeps the badge and `.humancov` manifest up to date automatically on every commit. -**Option A - via `humancov init`:** - Run `humancov init` and your AI tool will propose installing the hook for you. -**Option B - manual setup:** +
+Manual setup Create `.git/hooks/pre-commit` with this content: @@ -233,6 +243,8 @@ chmod +x .git/hooks/pre-commit > **Note:** `.git/hooks/` is local and not shared via git. Each contributor needs to set up the hook on their machine. If your team uses [husky](https://typicode.github.io/husky/) or a similar tool, add the hook content to your shared hooks directory instead. +
+ --- ## Manifest @@ -275,30 +287,6 @@ Aggregate stats and output report --- -## Performance - -Benchmarked on a standard machine with Node >= 18. Run `node bench/perf.js` to reproduce. - -| Operation | Scope | Avg | -|---|---|---| -| Comment prefix lookup | 10,000 lookups | ~12ms | -| Ignore pattern loading | single load | ~0.1ms | -| Badge URL generation | 1,000 generations | ~0.1ms | -| Header parsing | 7 files | ~3ms | -| Full repo scan | 8 tracked files | ~90ms | - -**Scaling (scanFiles):** - -| Files | Avg | Min | Max | -|---|---|---|---| -| 100 | ~120ms | ~80ms | ~220ms | -| 500 | ~175ms | ~145ms | ~295ms | -| 1,000 | ~265ms | ~220ms | ~340ms | - -Scales near-linearly. The main cost is `git ls-files` + file I/O in the scanner - utility modules add negligible overhead. - ---- - ## Spec See the [AI-Provenance Spec v0.1](https://github.com/enixCode/humancov/blob/main/CLAUDE.md) for the full specification. diff --git a/docs/index.html b/docs/index.html index 98fd79d..a9a6214 100644 --- a/docs/index.html +++ b/docs/index.html @@ -497,7 +497,7 @@

Review and track

Teach your AI to tag its code.

-

Run humancov init to detect your AI tool config files and inject provenance instructions automatically.

+

Run humancov init to detect your AI tool config files (or modern rule directories) and inject provenance instructions automatically.

@@ -509,12 +509,21 @@

Teach your AI to tag its code.

Claude Code
+
+
+ +
+
+
AGENTS.md
+
Cross-tool standard
+
+
-
.cursorrules
+
.cursorrules / .cursor/rules/
Cursor
@@ -523,7 +532,7 @@

Teach your AI to tag its code.

-
.windsurfrules
+
.windsurfrules / .windsurf/rules/
Windsurf
@@ -548,10 +557,11 @@

Teach your AI to tag its code.

$ humancov init
 
   done: CLAUDE.md (Claude Code instructions added)
-  done: .cursorrules (Cursor instructions added)
+  done: AGENTS.md (cross-tool instructions added)
+  done: .cursor/rules/humancov.mdc (Cursor rule file created)
   skip: .windsurfrules (already has AI-Provenance instructions)
 
-2 file(s) updated.
+3 file(s) updated. @@ -565,15 +575,55 @@

Teach your AI to tag its code.

Ready in seconds.

- npm install -g humancov + npm install --save-dev humancov click to copy

- Requires Node >= 18. Zero config. Single dependency. + Install as a dev dependency. Requires Node >= 18. Zero config. Single dependency.

+ +
+
+
+ +

Pre-commit hook.

+
+
+

A local git hook that auto-updates the badge and .humancov manifest before every commit. Zero manual work to keep your README and manifest in sync.

+
+
+
+
1
+
+

Runs on every git commit

+

Regenerates .humancov from your file headers and stages it.

+
+
+
+
2
+
+

Updates the README badge

+

Replaces the human-reviewed shields.io URL with the fresh percentage.

+
+
+
+
3
+
+

Stages the changes

+

Both .humancov and README.md updates land in the same commit.

+
+
+
+
+

Run humancov init and your AI tool will propose installing the hook for you. Manual setup: create .git/hooks/pre-commit, paste the snippet from the README, and chmod +x it.

+

Note: .git/hooks/ is local and not shared via git. Each contributor installs it once on their machine (or uses husky to share it).

+
+
+
+
@@ -666,6 +716,39 @@

30+ file types supported.

+ +
+
+
+ +

Skip files from scans.

+
+
+

humancov auto-respects your .gitignore - zero setup. For humancov-specific exclusions on top, create .humancov-ignore at the repo root (same gitignore syntax).

+

Resolution order: built-in defaults → .gitignore.humancov-ignore.

+
+
+
+
+
+
+ .humancov-ignore +
+
+
# exclude docs, lockfiles, build output
+*.md
+LICENSE
+*.lock
+dist/
+coverage/
+
+
+

+ Default ignores: node_modules/, .git/, .humancov, .humancov-ignore, *.md, *.lock, LICENSE, .gitignore, .github/. +

+
+
+
@@ -738,6 +821,34 @@

Enforce in your pipeline.

+ +
+
+
+ +

The .humancov file.

+
+
+

humancov manifest generates a TSV summary of all files with provenance headers. File headers are the source of truth - if both exist and conflict, headers win.

+
+
+
+
+
+
+ .humancov +
+
+
# .humancov
+# file              origin   reviewed   tested   generator     confidence
+src/scanner.js      ai       true       false    claude-code   high
+src/parser.js       ai       false      false    claude-code   -
+lib/utils.py        human    true       true     -             -
+
+
+
+
+
@@ -758,7 +869,8 @@

All commands.

$ humancov scan --badge # shields.io badge URL $ humancov scan --check 80 # CI gate: fail if < 80% $ humancov manifest # generate .humancov file -$ humancov init # add instructions to AI tool configs +$ humancov init # add instructions to AI tool configs +$ humancov --version # print version
@@ -800,7 +912,7 @@

All commands.

// Copy install command function copyInstall(el) { - navigator.clipboard.writeText('npm install -g humancov'); + navigator.clipboard.writeText('npm install --save-dev humancov'); const hint = el.querySelector('.copy-hint'); hint.textContent = 'copied!'; setTimeout(() => hint.textContent = 'click to copy', 1500); diff --git a/src/ignore.js b/src/ignore.js index d248d5a..ef0d007 100644 --- a/src/ignore.js +++ b/src/ignore.js @@ -19,15 +19,19 @@ const DEFAULTS = [ '.github/', ]; +const IGNORE_FILES = ['.gitignore', '.humancov-ignore']; + export function loadIgnore(rootDir) { const ig = ignore(); ig.add(DEFAULTS); - try { - const content = readFileSync(join(rootDir, '.humancov-ignore'), 'utf8'); - ig.add(content); - } catch { - // no ignore file - use defaults only + for (const file of IGNORE_FILES) { + try { + const content = readFileSync(join(rootDir, file), 'utf8'); + ig.add(content); + } catch { + // file not present - skip + } } return ig; diff --git a/src/init.js b/src/init.js index 5c123df..1928caa 100644 --- a/src/init.js +++ b/src/init.js @@ -3,14 +3,17 @@ // AI-Provenance-Reviewed: false // AI-Provenance-Tested: false -import { existsSync, readFileSync, appendFileSync } from 'node:fs'; -import { join } from 'node:path'; +import { existsSync, readFileSync, appendFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { join, dirname } from 'node:path'; const TOOL_CONFIGS = [ - { file: 'CLAUDE.md', generator: 'claude-code', name: 'Claude Code' }, - { file: '.cursorrules', generator: 'cursor', name: 'Cursor' }, - { file: '.windsurfrules', generator: 'windsurf', name: 'Windsurf' }, - { file: '.github/copilot-instructions.md', generator: 'copilot', name: 'GitHub Copilot' }, + { file: 'CLAUDE.md', generator: 'claude-code', name: 'Claude Code', action: 'append' }, + { file: 'AGENTS.md', generator: 'agents-md', name: 'AGENTS.md (cross-tool)', action: 'append' }, + { file: '.cursorrules', generator: 'cursor', name: 'Cursor (legacy)', action: 'append' }, + { file: '.cursor/rules/humancov.mdc', generator: 'cursor', name: 'Cursor (rules dir)', action: 'create-if-dir', dir: '.cursor/rules', format: 'mdc' }, + { file: '.windsurfrules', generator: 'windsurf', name: 'Windsurf (legacy)', action: 'append' }, + { file: '.windsurf/rules/humancov.md', generator: 'windsurf', name: 'Windsurf (rules dir)', action: 'create-if-dir', dir: '.windsurf/rules' }, + { file: '.github/copilot-instructions.md', generator: 'copilot', name: 'GitHub Copilot', action: 'append' }, ]; const HOOK_CONTENT = [ @@ -78,24 +81,58 @@ ${HOOK_INSTRUCTION} `; } +function buildMdcFile(generator) { + return `--- +description: humancov AI-Provenance headers +alwaysApply: true +--- +${buildInstructions(generator)}`; +} + export function runInit(rootDir) { let updated = 0; - for (const { file, generator, name } of TOOL_CONFIGS) { + for (const config of TOOL_CONFIGS) { + const { file, generator, name, action } = config; const filePath = join(rootDir, file); - if (!existsSync(filePath)) continue; + if (action === 'append') { + if (!existsSync(filePath)) continue; - const content = readFileSync(filePath, 'utf8'); + const content = readFileSync(filePath, 'utf8'); + if (content.includes('AI-Provenance-Origin')) { + console.log(` skip: ${file} (already has AI-Provenance instructions)`); + continue; + } - if (content.includes('AI-Provenance-Origin')) { - console.log(` skip: ${file} (already has AI-Provenance instructions)`); + appendFileSync(filePath, buildInstructions(generator)); + console.log(` done: ${file} (${name} instructions added)`); + updated++; continue; } - appendFileSync(filePath, buildInstructions(generator)); - console.log(` done: ${file} (${name} instructions added)`); - updated++; + if (action === 'create-if-dir') { + const dirPath = join(rootDir, config.dir); + if (!existsSync(dirPath)) continue; + + if (existsSync(filePath)) { + const content = readFileSync(filePath, 'utf8'); + if (content.includes('AI-Provenance-Origin')) { + console.log(` skip: ${file} (already has AI-Provenance instructions)`); + continue; + } + appendFileSync(filePath, buildInstructions(generator)); + console.log(` done: ${file} (${name} instructions appended)`); + updated++; + continue; + } + + mkdirSync(dirname(filePath), { recursive: true }); + const body = config.format === 'mdc' ? buildMdcFile(generator) : buildInstructions(generator).trimStart(); + writeFileSync(filePath, body); + console.log(` done: ${file} (${name} file created)`); + updated++; + } } if (updated === 0) { diff --git a/test/ignore.test.js b/test/ignore.test.js index 39f04cb..18befe9 100644 --- a/test/ignore.test.js +++ b/test/ignore.test.js @@ -65,4 +65,27 @@ describe('loadIgnore', () => { assert.ok(!ig.ignores('src/app.js')); cleanup(); }); + + it('loads .gitignore patterns automatically', () => { + setup(); + writeFileSync(join(TMP, '.gitignore'), 'build/\n*.log\nsecret.txt\n', 'utf8'); + const ig = loadIgnore(TMP); + assert.ok(ig.ignores('build/output.js')); + assert.ok(ig.ignores('debug.log')); + assert.ok(ig.ignores('secret.txt')); + // defaults still apply + assert.ok(ig.ignores('node_modules/foo.js')); + cleanup(); + }); + + it('combines .gitignore and .humancov-ignore', () => { + setup(); + writeFileSync(join(TMP, '.gitignore'), 'build/\n', 'utf8'); + writeFileSync(join(TMP, '.humancov-ignore'), 'vendor/\n', 'utf8'); + const ig = loadIgnore(TMP); + assert.ok(ig.ignores('build/x.js')); + assert.ok(ig.ignores('vendor/x.js')); + assert.ok(!ig.ignores('src/app.js')); + cleanup(); + }); }); diff --git a/test/init.test.js b/test/init.test.js new file mode 100644 index 0000000..304b366 --- /dev/null +++ b/test/init.test.js @@ -0,0 +1,110 @@ +// AI-Provenance-Origin: ai +// AI-Provenance-Generator: claude-code +// AI-Provenance-Reviewed: false +// AI-Provenance-Tested: false + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, writeFileSync, mkdirSync, rmSync, existsSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { runInit } from '../src/init.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const TMP = join(__dirname, '..', '.test-tmp-init'); + +function setup() { + rmSync(TMP, { recursive: true, force: true }); + mkdirSync(TMP, { recursive: true }); +} + +function cleanup() { + rmSync(TMP, { recursive: true, force: true }); +} + +describe('runInit', () => { + it('appends instructions to existing CLAUDE.md', () => { + setup(); + writeFileSync(join(TMP, 'CLAUDE.md'), '# My project\n'); + runInit(TMP); + const content = readFileSync(join(TMP, 'CLAUDE.md'), 'utf8'); + assert.ok(content.includes('AI-Provenance-Origin')); + assert.ok(content.includes('claude-code')); + cleanup(); + }); + + it('appends instructions to existing AGENTS.md', () => { + setup(); + writeFileSync(join(TMP, 'AGENTS.md'), '# Agents\n'); + runInit(TMP); + const content = readFileSync(join(TMP, 'AGENTS.md'), 'utf8'); + assert.ok(content.includes('AI-Provenance-Origin')); + assert.ok(content.includes('agents-md')); + cleanup(); + }); + + it('skips files that already contain AI-Provenance', () => { + setup(); + writeFileSync(join(TMP, 'CLAUDE.md'), '# My project\nAI-Provenance-Origin: ai\n'); + runInit(TMP); + const content = readFileSync(join(TMP, 'CLAUDE.md'), 'utf8'); + const matches = content.match(/AI-Provenance-Origin/g) || []; + assert.equal(matches.length, 1); + cleanup(); + }); + + it('does not create files that do not exist (for append-only configs)', () => { + setup(); + runInit(TMP); + assert.ok(!existsSync(join(TMP, 'CLAUDE.md'))); + assert.ok(!existsSync(join(TMP, 'AGENTS.md'))); + assert.ok(!existsSync(join(TMP, '.cursorrules'))); + cleanup(); + }); + + it('creates .cursor/rules/humancov.mdc when .cursor/rules dir exists', () => { + setup(); + mkdirSync(join(TMP, '.cursor', 'rules'), { recursive: true }); + runInit(TMP); + const filePath = join(TMP, '.cursor', 'rules', 'humancov.mdc'); + assert.ok(existsSync(filePath)); + const content = readFileSync(filePath, 'utf8'); + assert.ok(content.startsWith('---')); + assert.ok(content.includes('alwaysApply: true')); + assert.ok(content.includes('AI-Provenance-Origin')); + cleanup(); + }); + + it('creates .windsurf/rules/humancov.md when .windsurf/rules dir exists', () => { + setup(); + mkdirSync(join(TMP, '.windsurf', 'rules'), { recursive: true }); + runInit(TMP); + const filePath = join(TMP, '.windsurf', 'rules', 'humancov.md'); + assert.ok(existsSync(filePath)); + const content = readFileSync(filePath, 'utf8'); + assert.ok(content.includes('AI-Provenance-Origin')); + cleanup(); + }); + + it('skips rules dir files when parent dir does not exist', () => { + setup(); + runInit(TMP); + assert.ok(!existsSync(join(TMP, '.cursor', 'rules', 'humancov.mdc'))); + assert.ok(!existsSync(join(TMP, '.windsurf', 'rules', 'humancov.md'))); + cleanup(); + }); + + it('handles multiple configs in one run', () => { + setup(); + writeFileSync(join(TMP, 'CLAUDE.md'), '# Claude\n'); + writeFileSync(join(TMP, 'AGENTS.md'), '# Agents\n'); + writeFileSync(join(TMP, '.cursorrules'), '# Cursor legacy\n'); + mkdirSync(join(TMP, '.windsurf', 'rules'), { recursive: true }); + runInit(TMP); + assert.ok(readFileSync(join(TMP, 'CLAUDE.md'), 'utf8').includes('AI-Provenance-Origin')); + assert.ok(readFileSync(join(TMP, 'AGENTS.md'), 'utf8').includes('AI-Provenance-Origin')); + assert.ok(readFileSync(join(TMP, '.cursorrules'), 'utf8').includes('AI-Provenance-Origin')); + assert.ok(existsSync(join(TMP, '.windsurf', 'rules', 'humancov.md'))); + cleanup(); + }); +});