feat: initial open marketplace with llm-security, config-audit, ultraplan-local

This commit is contained in:
Kjell Tore Guttormsen 2026-04-06 18:47:49 +02:00
commit f93d6abdae
380 changed files with 65935 additions and 0 deletions

View file

@ -0,0 +1,3 @@
{
"name": "broken-plugin"
}

View file

@ -0,0 +1,8 @@
---
name: bad-agent
description: Missing model and tools
---
# Bad Agent
No model or tools in frontmatter.

View file

@ -0,0 +1,3 @@
# A command without frontmatter
This command has no YAML frontmatter.

View file

@ -0,0 +1,60 @@
Coding Standards and Best Practices
All code must be reviewed before merging to the main branch.
Every function must have a clear, single responsibility.
Variable names must be descriptive and follow camelCase convention.
Constants must be named in UPPER_SNAKE_CASE.
Avoid magic numbers; use named constants instead.
Keep line length under 120 characters.
Use four spaces for indentation, never tabs.
Files must end with a newline character.
Remove trailing whitespace from all lines.
Do not commit commented-out code.
Delete dead code instead of leaving it in place.
Write self-documenting code; comments explain why, not what.
All TODO comments must reference a ticket number.
Do not use abbreviations that are not widely understood.
Use positive variable names; prefer isActive over isNotInactive.
Avoid double negatives in conditional expressions.
Keep nesting levels to a maximum of three.
Extract complex conditions into named boolean variables.
Use early returns to reduce nesting.
Avoid else after return.
Keep functions under 40 lines of code.
Keep files under 300 lines of code.
Split large files into smaller, focused modules.
Use named exports, not default exports.
Group imports: standard library, external, internal.
Sort import groups alphabetically.
Do not use wildcard imports.
Remove unused imports before committing.
Use absolute imports for cross-module dependencies.
Use relative imports only within the same module.
Avoid circular dependencies between modules.
Use barrel files only at module boundaries.
Do not re-export from multiple barrel files.
Prefer named interfaces over inline type definitions.
Use generic types to avoid duplication.
Avoid type assertions unless absolutely necessary.
Do not use ts-ignore comments without explanation.
Enable strict mode in tsconfig.
Use unknown instead of any for unsafe types.
Prefer type narrowing over type assertions.
Use discriminated unions for complex state.
Model optional fields explicitly with undefined.
Avoid null; prefer undefined.
Use optional chaining for nullable access.
Use nullish coalescing for defaults.
Do not mix null and undefined in the same API.
Use enums for finite sets of values.
Prefer const enums for performance-sensitive code.
Do not extend enums dynamically.
Use readonly arrays and objects where mutation is unintended.
Prefer immutable data structures in shared state.
Avoid mutations in pure functions.
Use spread operators for shallow copies.
Use structuredClone for deep copies.
Do not mutate function parameters.
Return new objects from transformation functions.
Use Array methods over imperative loops where readable.
Avoid side effects in map and filter callbacks.

View file

@ -0,0 +1,6 @@
---
globs: nonexistent-dir/**/*.xyz
---
# Dead Rule
This rule matches nothing.

View file

@ -0,0 +1,7 @@
{
"includeCoAuthoredBy": true,
"alwaysThinkingEnabled": "yes",
"effortLevel": "turbo",
"unknownKey123": true,
"hooks": ["not", "an", "object"]
}

View file

@ -0,0 +1,24 @@
{
"mcpServers": {
"sse-server": {
"type": "sse",
"url": "https://api.example.com/mcp"
},
"unknown-type-server": {
"type": "grpc",
"command": "grpc-server"
},
"no-trust-server": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem"]
},
"missing-env-server": {
"type": "stdio",
"command": "npx",
"args": ["-y", "server", "${MISSING_API_KEY}", "--token", "${SECRET_TOKEN}"],
"extraField": true,
"anotherUnknown": "value"
}
}
}

View file

@ -0,0 +1,262 @@
Always use TypeScript for all code
Use ESLint and Prettier for code formatting.
Run linting before every commit.
Keep functions small and focused.
TODO: fix this linting config
Write unit tests for all business logic.
Use dependency injection where possible.
Avoid global state.
Always use TypeScript for all code
Document all public APIs with JSDoc.
Use interfaces over type aliases for objects.
Prefer readonly properties when possible.
Never use var, always use const or let.
TODO: fix this type definition
Use async/await instead of raw promises.
Handle errors explicitly, never swallow them.
Log errors with full context.
Use structured logging (JSON format).
Always use TypeScript for all code
Validate all inputs at service boundaries.
Sanitize all outputs before sending to clients.
Never hardcode secrets or credentials.
Use environment variables for configuration.
TODO: fix this environment variable handling
Always use TypeScript for all code
Keep configuration separate from code.
Use feature flags for experimental features.
Write integration tests for critical paths.
Use mocks for external dependencies in unit tests.
Prefer composition over inheritance.
Keep modules loosely coupled.
Use dependency inversion principle.
Separate concerns between layers.
Use repository pattern for data access.
Service layer should not know about HTTP.
Controllers should not contain business logic.
Use DTOs for data transfer between layers.
Validate DTOs at the entry point.
Use class-validator for DTO validation.
Use class-transformer for serialization.
Keep response shapes consistent.
Document API endpoints with OpenAPI.
Version your APIs from the start.
Use semantic versioning.
Tag releases in git.
Write a changelog for every release.
Squash commits before merging to main.
Write meaningful commit messages.
Use conventional commits format.
Link commits to issue tracker entries.
Review your own code before asking for review.
Use pull requests for all changes.
Require at least one review before merging.
Use CI checks to enforce quality gates.
Run tests in CI on every pull request.
Use branch protection rules on main.
Delete branches after merge.
Keep the main branch always deployable.
Use feature branches for development.
Rebase on main before merging.
Resolve conflicts locally before pushing.
Keep pull requests small and focused.
Add screenshots for UI changes.
Write a test plan in the PR description.
Reference related issues in pull requests.
Assign reviewers explicitly.
Respond to review comments promptly.
Mark resolved conversations.
Do not merge your own pull requests.
Check that all CI checks pass before merging.
Prefer squash merge strategy.
Update the changelog after merging.
Close related issues after merge.
Deploy after every merge to main.
Monitor deployments after release.
Roll back immediately if errors spike.
Use blue-green deployments for zero downtime.
Automate deployments using CI/CD pipelines.
Store infrastructure as code.
Use Terraform for infrastructure management.
Review infrastructure changes before applying.
Use remote state for Terraform.
Lock Terraform provider versions.
Document infrastructure decisions in ADRs.
Keep secrets out of infrastructure code.
Use a secrets manager for production secrets.
Rotate secrets regularly.
Audit access to secrets.
Use RBAC for authorization.
Apply least privilege principle.
Review permissions quarterly.
Log all privileged operations.
Use multi-factor authentication everywhere.
Enforce password policies.
Use SSO where possible.
Scan dependencies for vulnerabilities.
Update dependencies regularly.
Pin dependency versions in production.
Use a lock file for all package managers.
Review licenses of all dependencies.
Avoid dependencies with no maintenance.
Prefer smaller, focused packages.
Check bundle size impact of new dependencies.
Remove unused dependencies.
Run npm audit on every CI build.
Address high severity vulnerabilities immediately.
Track open vulnerabilities in issue tracker.
Set up automated dependency update PRs.
Review Dependabot PRs weekly.
Test dependency upgrades in a staging environment.
Keep Node.js version up to date.
Use LTS versions of Node.js.
Document the required Node.js version.
Use .nvmrc or .node-version files.
Enforce Node.js version in CI.
Use Docker for local development environments.
Keep Docker images small.
Use multi-stage builds for production images.
Scan Docker images for vulnerabilities.
Do not run containers as root.
Use read-only filesystems where possible.
Set resource limits on containers.
Use health checks in Docker containers.
Use named volumes for persistent data.
Document Docker networking configuration.
Use docker-compose for local multi-service setups.
Version docker-compose files.
Keep docker-compose files out of production.
Use Kubernetes for orchestration in production.
Define resource requests and limits for pods.
Use namespaces for environment separation.
Apply network policies between services.
Use readiness and liveness probes.
Configure horizontal pod autoscaling.
Use persistent volume claims for stateful services.
Back up persistent volumes regularly.
Test backup restoration periodically.
Monitor disk usage on all nodes.
Set up alerts for critical system metrics.
Use a centralized logging solution.
Retain logs for at least 90 days.
Archive logs to cold storage after 30 days.
Set up log-based alerting for errors.
Use distributed tracing for microservices.
Correlate logs and traces using request IDs.
Monitor API latency percentiles.
Set SLOs for all critical services.
Track error budget consumption.
Conduct post-mortems for all incidents.
Document runbooks for common incidents.
Keep runbooks up to date.
Test runbooks regularly.
Practice chaos engineering.
Define recovery time objectives.
Define recovery point objectives.
Test disaster recovery procedures annually.
Document on-call procedures.
Rotate on-call responsibilities.
Compensate on-call fairly.
Track on-call incidents and burnout signals.
Hold regular architecture review meetings.
Document decisions in architecture decision records.
Review and update ADRs as systems evolve.
Share architectural knowledge across the team.
Hold regular tech debt review sessions.
Prioritize tech debt alongside features.
Track tech debt in the issue tracker.
Set a tech debt budget per sprint.
Refactor incrementally, not in big bang rewrites.
Write tests before refactoring.
Measure test coverage trends over time.
Aim for meaningful coverage, not 100 percent.
Use mutation testing to assess test quality.
Avoid testing implementation details.
Test behavior, not structure.
Keep tests independent and isolated.
Use test data factories for complex objects.
Reset state between tests.
Avoid hardcoded test data.
Use realistic test data where possible.
Anonymize personal data in test datasets.
Never use production data in development.
Use database migrations for schema changes.
Test migrations before applying to production.
Make migrations reversible.
Run migrations in a transaction.
Seed databases for development and testing.
Keep seed data minimal and representative.
Document database schema changes.
Index columns used in frequent queries.
Monitor query performance in production.
Use query explain plans to diagnose slow queries.
Avoid N+1 queries.
Cache aggressively but invalidate correctly.
Use Redis for distributed caching.
Set TTLs on all cache entries.
Monitor cache hit rates.
Warm caches after deployment.
Use CDN for static assets.
Enable HTTP/2 and HTTP/3 where possible.
Compress responses with gzip or brotli.
Minimize JavaScript bundle sizes.
Lazy load non-critical resources.
Measure and budget page load performance.
Use Lighthouse for performance auditing.
Set performance regression budgets in CI.
Monitor Core Web Vitals in production.
Use server-side rendering for SEO-critical pages.
Pre-render static pages where possible.
Use incremental static regeneration when applicable.
Test accessibility with automated tools.
Fix all critical accessibility issues before launch.
Test with real assistive technologies.
Follow WCAG 2.1 AA guidelines.
Provide text alternatives for all images.
Ensure sufficient color contrast.
Make all interactive elements keyboard accessible.
Use semantic HTML elements.
Add ARIA attributes only when necessary.
Test with users with disabilities when possible.
Document accessibility decisions.
Include accessibility in the definition of done.
Train the team on accessibility basics.
Review accessibility in code review.
Track accessibility issues separately.
Prioritize accessibility issues appropriately.
Celebrate accessibility improvements.
Share accessibility learnings across projects.
Stay up to date with accessibility standards.
Advocate for accessibility in product planning.
Perform regular security audits.
Use static analysis tools for security scanning.
Integrate SAST into CI pipelines.
Review OWASP Top 10 annually.
Train developers on secure coding practices.
Track security findings in the issue tracker.
Address critical security issues within 24 hours.
Address high security issues within one week.
Conduct penetration testing before major releases.
Document security threat models.
Review threat models when architecture changes.
Use Content Security Policy headers.
Set security headers on all HTTP responses.
Use HTTPS everywhere.
Redirect HTTP to HTTPS.
Use HSTS with a long max-age.
Validate and escape all user input.
Use parameterized queries for database access.
Avoid SQL string concatenation.
Use prepared statements.
Sanitize file paths before using them.
Use allowlists for file extension validation.
Never trust client-supplied file names.
Limit file upload sizes.
Scan uploaded files for malware.
Store uploaded files outside the web root.
Use signed URLs for serving uploaded files.
Expire signed URLs appropriately.
Audit file access logs regularly.
Use rate limiting on all public endpoints.
@imports/a.md
@docs/nonexistent.md

View file

@ -0,0 +1,26 @@
{
"hooks": {
"InvalidEvent": [
{
"hooks": [
{
"type": "command",
"command": "echo test"
}
]
}
],
"PreToolUse": [
{
"matcher": {"tool": "Bash"},
"hooks": [
{
"type": "invalid_type",
"command": "echo test",
"timeout": 500
}
]
}
]
}
}

View file

@ -0,0 +1,3 @@
# Import A
Shared content from file A.
@b.md

View file

@ -0,0 +1,3 @@
# Import B
Shared content from file B.
@a.md

View file

@ -0,0 +1,16 @@
{
"model": "claude-sonnet-4-5",
"effortLevel": "high",
"permissions": {
"allow": ["Bash(npm run *)", "Read(src/**)"],
"deny": []
},
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [{ "type": "command", "command": "echo project-hook" }]
}
]
}
}

View file

@ -0,0 +1,7 @@
# Conflict Test Project
## Overview
A test project with intentional configuration conflicts across scopes.
## Commands
- `npm test` — Run tests

View file

@ -0,0 +1,2 @@
This rule file has the wrong extension.
It should be .md to be loaded by Claude Code.

View file

@ -0,0 +1,8 @@
---
globs: "**/*.ts"
---
# TypeScript Rules
- Use strict mode
- Prefer interfaces over types

View file

@ -0,0 +1,14 @@
{
"apiProvider": "anthropic",
"permissions": {
"allow": []
},
"alwaysThinkingEnabled": "true",
"effortLevel": "turbo",
"hooks": [
{
"event": "PreToolUse",
"command": "echo ok"
}
]
}

View file

@ -0,0 +1,2 @@
# Suppress known feature gap findings for this test fixture
CA-GAP-*

View file

@ -0,0 +1,7 @@
# Fixable Project
This is a minimal CLAUDE.md for the fixable-project fixture.
## Rules
- Follow TypeScript conventions

View file

@ -0,0 +1,18 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": {
"tool": "Bash"
},
"hooks": [
{
"type": "command",
"command": "echo ok",
"timeout": "5000"
}
]
}
]
}
}

View file

@ -0,0 +1,6 @@
---
paths: src/**/*.ts
---
# TypeScript Rules
Use strict TypeScript. No `any` types.

View file

@ -0,0 +1,7 @@
{
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"permissions": {
"allow": ["Bash(npm run *)"],
"deny": ["Read(./.env)"]
}
}

View file

@ -0,0 +1,7 @@
# Shared Configuration
Common patterns and conventions shared across the project.
## Naming Conventions
- Use camelCase for variables and functions
- Use PascalCase for classes and types

View file

@ -0,0 +1,16 @@
{
"mcpServers": {
"memory": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-memory"],
"trust": "workspace"
},
"filesystem": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "./docs"],
"trust": "trusted"
}
}
}

View file

@ -0,0 +1,17 @@
# My Project
## Overview
A sample project for testing config-audit scanners.
## Commands
- `npm run build` — Build the project
- `npm test` — Run tests
## Architecture
Standard Node.js project structure.
## Conventions
- TypeScript preferred
- Conventional commits
@.claude/shared.md

View file

@ -0,0 +1,16 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "echo ok",
"timeout": 5000
}
]
}
]
}
}

View file

@ -0,0 +1 @@
export {};

View file

@ -0,0 +1 @@
# Project

View file

@ -0,0 +1,5 @@
{
"name": "test-plugin",
"description": "A test plugin for config-audit plugin-health scanner",
"version": "1.0.0"
}

View file

@ -0,0 +1,21 @@
# Test Plugin
A test plugin for validating plugin-health scanner.
## Commands
| Command | Description |
|---------|-------------|
| `/test-plugin:test-cmd` | A test command |
## Agents
| Agent | Role | Model |
|-------|------|-------|
| test-agent | Test agent | sonnet |
## Hooks
| Event | Script | Purpose |
|-------|--------|---------|
| PreToolUse | test-hook.mjs | Test hook |

View file

@ -0,0 +1,10 @@
---
name: test-agent
description: A test agent for validation
model: sonnet
tools: ["Read", "Glob"]
---
# Test Agent
A test agent.

View file

@ -0,0 +1,10 @@
---
name: test-plugin:test-cmd
description: A test command
allowed-tools: Read, Bash
model: sonnet
---
# Test Command
This is a test command.

View file

@ -0,0 +1,15 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "echo test"
}
]
}
]
}
}

View file

@ -0,0 +1,87 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { resolve } from 'node:path';
const execFileAsync = promisify(execFile);
const HOOK_PATH = resolve(import.meta.dirname, '../../hooks/scripts/post-edit-verify.mjs');
/**
* Run the hook with given stdin input (string).
* Short timeout these tests should only exercise fast paths.
*/
async function runHook(inputStr, timeout = 10000) {
try {
const { stdout } = await execFileAsync('node', [HOOK_PATH], {
input: inputStr,
timeout,
});
return JSON.parse(stdout || '{}');
} catch (err) {
if (err.stdout) {
try { return JSON.parse(err.stdout); } catch { /* ignore */ }
}
return {};
}
}
// ========================================
// post-edit-verify hook — fast-path tests
// Tests exercise the early-exit paths (non-config, missing file, invalid input).
// Scanner execution paths are covered by scanner tests.
// ========================================
describe('post-edit-verify hook', () => {
it('returns {} for non-config files', async () => {
const result = await runHook(JSON.stringify({ file_path: '/tmp/some-random-file.js' }));
assert.deepEqual(result, {});
});
it('returns {} for nonexistent config files', async () => {
const result = await runHook(JSON.stringify({ file_path: '/nonexistent/CLAUDE.md' }));
assert.deepEqual(result, {});
});
it('returns {} for empty input object', async () => {
const result = await runHook(JSON.stringify({}));
assert.deepEqual(result, {});
});
it('returns {} for null file_path', async () => {
const result = await runHook(JSON.stringify({ file_path: null }));
assert.deepEqual(result, {});
});
it('handles invalid JSON gracefully', async () => {
const result = await runHook('not json at all');
assert.deepEqual(result, {});
});
it('handles empty stdin gracefully', async () => {
const result = await runHook('');
assert.deepEqual(result, {});
});
it('exits quickly for non-config files', async () => {
const start = Date.now();
await runHook(JSON.stringify({ file_path: '/tmp/nothing.txt' }));
const elapsed = Date.now() - start;
// Non-config files exit before any scanner import — should be fast
assert.ok(elapsed < 5000, `Hook took ${elapsed}ms for non-config file`);
});
it('has correct shebang and is valid Node.js', async () => {
const { readFile } = await import('node:fs/promises');
const content = await readFile(HOOK_PATH, 'utf-8');
assert.ok(content.startsWith('#!/usr/bin/env node'));
assert.ok(content.includes('readFileSync'));
assert.ok(content.includes('detectScanner'));
});
it('uses cross-platform rules dir pattern (handles both / and \\)', async () => {
const { readFile } = await import('node:fs/promises');
const content = await readFile(HOOK_PATH, 'utf-8');
// The regex should handle both Unix / and Windows \\ separators
assert.ok(content.includes('[/\\\\]rules[/\\\\]'), 'RULES_DIR_PATTERN should match both / and \\\\');
});
});

View file

@ -0,0 +1,176 @@
import { describe, it, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { join } from 'node:path';
import { rm, stat, readFile, mkdir } from 'node:fs/promises';
import { tmpdir, homedir } from 'node:os';
import {
saveBaseline,
loadBaseline,
listBaselines,
deleteBaseline,
getBaselinesDir,
} from '../../scanners/lib/baseline.mjs';
// We test against the real baselines dir but use unique names to avoid collisions.
const TEST_PREFIX = `_test_${Date.now()}_`;
function makeTestEnvelope(findingCount = 3) {
const findings = Array.from({ length: findingCount }, (_, i) => ({
id: `CA-CML-${String(i + 1).padStart(3, '0')}`,
scanner: 'CML',
severity: 'low',
title: `Finding ${i + 1}`,
file: 'CLAUDE.md',
}));
return {
meta: { target: '/test/path', timestamp: new Date().toISOString(), version: '2.0.0', tool: 'config-audit' },
scanners: [{
scanner: 'CML',
status: 'ok',
files_scanned: 1,
duration_ms: 5,
findings,
counts: { critical: 0, high: 0, medium: 0, low: findingCount, info: 0 },
}],
aggregate: { total_findings: findingCount, counts: { critical: 0, high: 0, medium: 0, low: findingCount, info: 0 }, risk_score: findingCount, risk_band: 'Low', verdict: 'PASS', scanners_ok: 1, scanners_error: 0, scanners_skipped: 0 },
};
}
// Cleanup helper
const savedNames = [];
async function cleanup() {
for (const name of savedNames) {
await deleteBaseline(name);
}
savedNames.length = 0;
}
describe('saveBaseline', () => {
afterEach(cleanup);
it('writes a file and returns path', async () => {
const name = `${TEST_PREFIX}save1`;
savedNames.push(name);
const envelope = makeTestEnvelope(2);
const result = await saveBaseline(envelope, name);
assert.ok(result.path.endsWith(`${name}.json`));
assert.equal(result.name, name);
// Verify file exists
const s = await stat(result.path);
assert.ok(s.isFile());
});
it('includes _baseline metadata', async () => {
const name = `${TEST_PREFIX}meta`;
savedNames.push(name);
const envelope = makeTestEnvelope(5);
await saveBaseline(envelope, name);
const loaded = await loadBaseline(name);
assert.ok(loaded._baseline);
assert.ok(loaded._baseline.saved_at);
assert.equal(loaded._baseline.target_path, '/test/path');
assert.equal(loaded._baseline.finding_count, 5);
assert.equal(typeof loaded._baseline.score, 'number');
});
it('defaults name to "default"', async () => {
const name = `${TEST_PREFIX}default_test`;
savedNames.push(name);
// We won't actually use the literal 'default' to avoid interfering with real baselines
const result = await saveBaseline(makeTestEnvelope(), name);
assert.equal(result.name, name);
});
it('overwrites existing baseline with same name', async () => {
const name = `${TEST_PREFIX}overwrite`;
savedNames.push(name);
await saveBaseline(makeTestEnvelope(2), name);
await saveBaseline(makeTestEnvelope(7), name);
const loaded = await loadBaseline(name);
assert.equal(loaded._baseline.finding_count, 7);
});
});
describe('loadBaseline', () => {
afterEach(cleanup);
it('loads a previously saved baseline', async () => {
const name = `${TEST_PREFIX}load`;
savedNames.push(name);
const envelope = makeTestEnvelope(3);
await saveBaseline(envelope, name);
const loaded = await loadBaseline(name);
assert.ok(loaded);
assert.equal(loaded.aggregate.total_findings, 3);
assert.equal(loaded.meta.target, '/test/path');
});
it('returns null for unknown name', async () => {
const result = await loadBaseline(`${TEST_PREFIX}nonexistent_${Date.now()}`);
assert.equal(result, null);
});
it('preserves all scanner data', async () => {
const name = `${TEST_PREFIX}preserve`;
savedNames.push(name);
const envelope = makeTestEnvelope(1);
await saveBaseline(envelope, name);
const loaded = await loadBaseline(name);
assert.equal(loaded.scanners.length, 1);
assert.equal(loaded.scanners[0].scanner, 'CML');
assert.equal(loaded.scanners[0].findings.length, 1);
});
});
describe('listBaselines', () => {
afterEach(cleanup);
it('lists saved baselines', async () => {
const name = `${TEST_PREFIX}list`;
savedNames.push(name);
await saveBaseline(makeTestEnvelope(4), name);
const result = await listBaselines();
assert.ok(Array.isArray(result.baselines));
const found = result.baselines.find(b => b.name === name);
assert.ok(found, 'Should find the saved baseline in list');
assert.equal(found.findingCount, 4);
assert.ok(found.savedAt);
});
it('returns empty array when no baselines', async () => {
// This test just verifies the function doesn't crash
const result = await listBaselines();
assert.ok(Array.isArray(result.baselines));
});
});
describe('deleteBaseline', () => {
it('deletes an existing baseline', async () => {
const name = `${TEST_PREFIX}delete`;
await saveBaseline(makeTestEnvelope(), name);
const result = await deleteBaseline(name);
assert.equal(result.deleted, true);
const loaded = await loadBaseline(name);
assert.equal(loaded, null);
});
it('returns false for non-existent baseline', async () => {
const result = await deleteBaseline(`${TEST_PREFIX}nope_${Date.now()}`);
assert.equal(result.deleted, false);
});
});

View file

@ -0,0 +1,288 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { diffEnvelopes, formatDiffReport } from '../../scanners/lib/diff-engine.mjs';
// --- Helpers ---
function makeFinding(scanner, title, severity = 'medium', file = null) {
return {
id: `CA-${scanner}-001`,
scanner,
severity,
title,
description: `Description for ${title}`,
file,
line: null,
evidence: null,
category: null,
recommendation: null,
autoFixable: false,
};
}
function makeScannerResult(scanner, findings) {
const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
for (const f of findings) {
if (counts[f.severity] !== undefined) counts[f.severity]++;
}
return {
scanner,
status: 'ok',
files_scanned: 3,
duration_ms: 10,
findings,
counts,
};
}
function makeEnvelope(scannerResults) {
const aggregate = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
let total = 0;
for (const r of scannerResults) {
for (const sev of Object.keys(aggregate)) {
aggregate[sev] += (r.counts[sev] || 0);
}
total += r.findings.length;
}
return {
meta: { target: '/test', timestamp: new Date().toISOString(), version: '2.0.0', tool: 'config-audit' },
scanners: scannerResults,
aggregate: { total_findings: total, counts: aggregate, risk_score: 0, risk_band: 'Low', verdict: 'PASS', scanners_ok: scannerResults.length, scanners_error: 0, scanners_skipped: 0 },
};
}
// ========================================
// diffEnvelopes
// ========================================
describe('diffEnvelopes', () => {
it('identifies new findings', () => {
const baseline = makeEnvelope([makeScannerResult('CML', [])]);
const current = makeEnvelope([
makeScannerResult('CML', [makeFinding('CML', 'New issue', 'high', 'CLAUDE.md')]),
]);
const diff = diffEnvelopes(baseline, current);
assert.equal(diff.newFindings.length, 1);
assert.equal(diff.newFindings[0].title, 'New issue');
assert.equal(diff.resolvedFindings.length, 0);
});
it('identifies resolved findings', () => {
const baseline = makeEnvelope([
makeScannerResult('CML', [makeFinding('CML', 'Old issue', 'high', 'CLAUDE.md')]),
]);
const current = makeEnvelope([makeScannerResult('CML', [])]);
const diff = diffEnvelopes(baseline, current);
assert.equal(diff.resolvedFindings.length, 1);
assert.equal(diff.resolvedFindings[0].title, 'Old issue');
assert.equal(diff.newFindings.length, 0);
});
it('identifies unchanged findings', () => {
const f = makeFinding('CML', 'Persistent issue', 'medium', 'CLAUDE.md');
const baseline = makeEnvelope([makeScannerResult('CML', [f])]);
const current = makeEnvelope([makeScannerResult('CML', [{ ...f }])]);
const diff = diffEnvelopes(baseline, current);
assert.equal(diff.unchangedFindings.length, 1);
assert.equal(diff.newFindings.length, 0);
assert.equal(diff.resolvedFindings.length, 0);
});
it('detects moved findings (same title, different file)', () => {
const baseFinding = makeFinding('CML', 'Moved issue', 'high', 'old-file.md');
const currFinding = makeFinding('CML', 'Moved issue', 'high', 'new-file.md');
const baseline = makeEnvelope([makeScannerResult('CML', [baseFinding])]);
const current = makeEnvelope([makeScannerResult('CML', [currFinding])]);
const diff = diffEnvelopes(baseline, current);
assert.equal(diff.movedFindings.length, 1);
assert.equal(diff.movedFindings[0].from.file, 'old-file.md');
assert.equal(diff.movedFindings[0].to.file, 'new-file.md');
assert.equal(diff.newFindings.length, 0);
assert.equal(diff.resolvedFindings.length, 0);
});
it('calculates score delta', () => {
const baseline = makeEnvelope([
makeScannerResult('CML', [
makeFinding('CML', 'A', 'high', 'a.md'),
makeFinding('CML', 'B', 'high', 'a.md'),
makeFinding('CML', 'C', 'high', 'a.md'),
]),
]);
const current = makeEnvelope([makeScannerResult('CML', [])]);
const diff = diffEnvelopes(baseline, current);
assert.ok(diff.scoreChange.delta > 0, 'Score should improve when findings are resolved');
assert.equal(typeof diff.scoreChange.before.grade, 'string');
assert.equal(typeof diff.scoreChange.after.grade, 'string');
});
it('calculates area changes', () => {
const baseline = makeEnvelope([
makeScannerResult('CML', [makeFinding('CML', 'X', 'low', 'a.md')]),
makeScannerResult('SET', []),
]);
const current = makeEnvelope([
makeScannerResult('CML', []),
makeScannerResult('SET', [makeFinding('SET', 'Y', 'low', 'b.json')]),
]);
const diff = diffEnvelopes(baseline, current);
assert.ok(diff.areaChanges.length >= 2);
const cmlChange = diff.areaChanges.find(a => a.name === 'CLAUDE.md');
assert.ok(cmlChange, 'Should have CLAUDE.md area change');
assert.ok(cmlChange.delta > 0, 'CLAUDE.md should improve');
});
it('detects improving trend', () => {
const baseline = makeEnvelope([
makeScannerResult('CML', [
makeFinding('CML', 'A', 'high', 'a.md'),
makeFinding('CML', 'B', 'high', 'a.md'),
]),
]);
const current = makeEnvelope([makeScannerResult('CML', [])]);
const diff = diffEnvelopes(baseline, current);
assert.equal(diff.summary.trend, 'improving');
});
it('detects degrading trend', () => {
const baseline = makeEnvelope([makeScannerResult('CML', [])]);
const current = makeEnvelope([
makeScannerResult('CML', [
makeFinding('CML', 'A', 'high', 'a.md'),
makeFinding('CML', 'B', 'high', 'a.md'),
]),
]);
const diff = diffEnvelopes(baseline, current);
assert.equal(diff.summary.trend, 'degrading');
});
it('detects stable trend (same findings)', () => {
const f = makeFinding('CML', 'Same', 'medium', 'a.md');
const baseline = makeEnvelope([makeScannerResult('CML', [f])]);
const current = makeEnvelope([makeScannerResult('CML', [{ ...f }])]);
const diff = diffEnvelopes(baseline, current);
assert.equal(diff.summary.trend, 'stable');
});
it('handles empty baseline', () => {
const baseline = makeEnvelope([]);
const current = makeEnvelope([
makeScannerResult('CML', [makeFinding('CML', 'New', 'low', 'a.md')]),
]);
const diff = diffEnvelopes(baseline, current);
assert.equal(diff.newFindings.length, 1);
assert.equal(diff.resolvedFindings.length, 0);
assert.equal(diff.summary.totalBefore, 0);
});
it('handles identical envelopes (all unchanged)', () => {
const f1 = makeFinding('CML', 'Issue A', 'medium', 'a.md');
const f2 = makeFinding('SET', 'Issue B', 'low', 'b.json');
const baseline = makeEnvelope([
makeScannerResult('CML', [f1]),
makeScannerResult('SET', [f2]),
]);
const current = makeEnvelope([
makeScannerResult('CML', [{ ...f1 }]),
makeScannerResult('SET', [{ ...f2 }]),
]);
const diff = diffEnvelopes(baseline, current);
assert.equal(diff.unchangedFindings.length, 2);
assert.equal(diff.newFindings.length, 0);
assert.equal(diff.resolvedFindings.length, 0);
assert.equal(diff.movedFindings.length, 0);
assert.equal(diff.summary.trend, 'stable');
});
it('summary has correct counts', () => {
const baseline = makeEnvelope([
makeScannerResult('CML', [
makeFinding('CML', 'Keep', 'low', 'a.md'),
makeFinding('CML', 'Resolve', 'high', 'b.md'),
]),
]);
const current = makeEnvelope([
makeScannerResult('CML', [
makeFinding('CML', 'Keep', 'low', 'a.md'),
makeFinding('CML', 'Brand new', 'medium', 'c.md'),
]),
]);
const diff = diffEnvelopes(baseline, current);
assert.equal(diff.summary.totalBefore, 2);
assert.equal(diff.summary.totalAfter, 2);
assert.equal(diff.summary.newCount, 1);
assert.equal(diff.summary.resolvedCount, 1);
});
it('handles findings with null file gracefully', () => {
const f = makeFinding('CML', 'No file', 'info', null);
const baseline = makeEnvelope([makeScannerResult('CML', [f])]);
const current = makeEnvelope([makeScannerResult('CML', [{ ...f }])]);
const diff = diffEnvelopes(baseline, current);
assert.equal(diff.unchangedFindings.length, 1);
});
it('multiple findings with same key are matched correctly', () => {
const f1 = makeFinding('CML', 'Duplicate', 'low', 'a.md');
const f2 = makeFinding('CML', 'Duplicate', 'low', 'a.md');
const baseline = makeEnvelope([makeScannerResult('CML', [f1, f2])]);
const current = makeEnvelope([makeScannerResult('CML', [{ ...f1 }])]);
const diff = diffEnvelopes(baseline, current);
assert.equal(diff.unchangedFindings.length, 1);
assert.equal(diff.resolvedFindings.length, 1);
});
});
// ========================================
// formatDiffReport
// ========================================
describe('formatDiffReport', () => {
it('returns a non-empty string', () => {
const diff = diffEnvelopes(makeEnvelope([]), makeEnvelope([]));
const report = formatDiffReport(diff);
assert.ok(report.length > 0);
assert.equal(typeof report, 'string');
});
it('contains header', () => {
const diff = diffEnvelopes(makeEnvelope([]), makeEnvelope([]));
const report = formatDiffReport(diff);
assert.ok(report.includes('Config-Audit Drift Report'));
});
it('shows trend', () => {
const baseline = makeEnvelope([
makeScannerResult('CML', [makeFinding('CML', 'X', 'high', 'a.md')]),
]);
const current = makeEnvelope([makeScannerResult('CML', [])]);
const diff = diffEnvelopes(baseline, current);
const report = formatDiffReport(diff);
assert.ok(report.includes('Improving'));
});
it('lists new findings', () => {
const baseline = makeEnvelope([makeScannerResult('CML', [])]);
const current = makeEnvelope([
makeScannerResult('CML', [makeFinding('CML', 'Fresh issue', 'high', 'x.md')]),
]);
const diff = diffEnvelopes(baseline, current);
const report = formatDiffReport(diff);
assert.ok(report.includes('Fresh issue'));
assert.ok(report.includes('New findings'));
});
});

View file

@ -0,0 +1,391 @@
import { describe, it, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { join } from 'node:path';
import { mkdir, writeFile, rm, stat } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import {
discoverConfigFiles,
discoverConfigFilesMulti,
discoverFullMachinePaths,
readTextFile,
} from '../../scanners/lib/file-discovery.mjs';
/**
* Create a temp directory with a unique name for test isolation.
*/
function tempDir(suffix) {
return join(tmpdir(), `config-audit-fd-test-${suffix}-${Date.now()}`);
}
// ───────────────────────────────────────────────────────────────
// Group 1: discoverConfigFiles — single path
// ───────────────────────────────────────────────────────────────
describe('discoverConfigFiles (single path)', () => {
let dir;
before(async () => {
dir = tempDir('single');
// Create a realistic project structure
await mkdir(join(dir, '.claude', 'rules'), { recursive: true });
await mkdir(join(dir, 'hooks'), { recursive: true });
await mkdir(join(dir, 'node_modules', 'pkg'), { recursive: true });
await mkdir(join(dir, 'src'), { recursive: true });
await writeFile(join(dir, 'CLAUDE.md'), '# Instructions');
await writeFile(join(dir, '.claude', 'settings.json'), '{}');
await writeFile(join(dir, '.claude', 'rules', 'my-rule.md'), '---\nglobs: "*.ts"\n---\nRule');
await writeFile(join(dir, '.mcp.json'), '{"mcpServers": {}}');
await writeFile(join(dir, 'hooks', 'hooks.json'), '{"hooks": {}}');
await writeFile(join(dir, 'src', 'index.ts'), 'export {}');
// Should be skipped
await writeFile(join(dir, 'node_modules', 'pkg', 'CLAUDE.md'), '# dep');
});
after(async () => {
await rm(dir, { recursive: true, force: true });
});
it('returns { files, skipped } shape', async () => {
const result = await discoverConfigFiles(dir);
assert.ok(Array.isArray(result.files));
assert.equal(typeof result.skipped, 'number');
});
it('finds CLAUDE.md', async () => {
const result = await discoverConfigFiles(dir);
const claude = result.files.find(f => f.type === 'claude-md');
assert.ok(claude, 'should find CLAUDE.md');
assert.equal(claude.relPath, 'CLAUDE.md');
});
it('finds settings.json in .claude/', async () => {
const result = await discoverConfigFiles(dir);
const settings = result.files.find(f => f.type === 'settings-json');
assert.ok(settings, 'should find settings.json');
assert.ok(settings.relPath.includes('.claude'));
});
it('finds .mcp.json', async () => {
const result = await discoverConfigFiles(dir);
const mcp = result.files.find(f => f.type === 'mcp-json');
assert.ok(mcp, 'should find .mcp.json');
});
it('finds rules in .claude/rules/', async () => {
const result = await discoverConfigFiles(dir);
const rules = result.files.filter(f => f.type === 'rule');
assert.ok(rules.length > 0, 'should find at least one rule');
});
it('finds hooks.json', async () => {
const result = await discoverConfigFiles(dir);
const hooks = result.files.find(f => f.type === 'hooks-json');
assert.ok(hooks, 'should find hooks.json');
});
it('skips node_modules', async () => {
const result = await discoverConfigFiles(dir);
const inNodeModules = result.files.filter(f => f.absPath.includes('node_modules'));
assert.equal(inNodeModules.length, 0, 'should not include files in node_modules');
});
it('tracks skipped directories (counter fix)', async () => {
const result = await discoverConfigFiles(dir);
assert.ok(result.skipped >= 1, 'should count at least node_modules as skipped');
});
it('does not include non-config files', async () => {
const result = await discoverConfigFiles(dir);
const ts = result.files.find(f => f.absPath.endsWith('.ts'));
assert.equal(ts, undefined, 'should not include .ts files');
});
});
// ───────────────────────────────────────────────────────────────
// Group 2: File classification
// ───────────────────────────────────────────────────────────────
describe('file classification via discoverConfigFiles', () => {
let dir;
before(async () => {
dir = tempDir('classify');
await mkdir(join(dir, '.claude-plugin'), { recursive: true });
await mkdir(join(dir, 'agents'), { recursive: true });
await mkdir(join(dir, 'commands'), { recursive: true });
await mkdir(join(dir, 'skills', 'my-skill'), { recursive: true });
await mkdir(join(dir, 'hooks'), { recursive: true });
await writeFile(join(dir, '.claude-plugin', 'plugin.json'), '{}');
await writeFile(join(dir, 'agents', 'test-agent.md'), '---\nname: test\n---');
await writeFile(join(dir, 'commands', 'test-cmd.md'), '---\nname: test\n---');
await writeFile(join(dir, 'skills', 'my-skill', 'SKILL.md'), '# Skill');
await writeFile(join(dir, 'hooks', 'hooks.json'), '{}');
await writeFile(join(dir, 'CLAUDE.local.md'), '# Local');
// Not a config file
await writeFile(join(dir, 'random.txt'), 'hello');
});
after(async () => {
await rm(dir, { recursive: true, force: true });
});
it('classifies plugin.json in .claude-plugin/', async () => {
const { files } = await discoverConfigFiles(dir);
const plugin = files.find(f => f.type === 'plugin-json');
assert.ok(plugin, 'should find plugin.json');
});
it('classifies agent .md in agents/', async () => {
const { files } = await discoverConfigFiles(dir);
const agent = files.find(f => f.type === 'agent-md');
assert.ok(agent, 'should find agent markdown');
});
it('classifies command .md in commands/', async () => {
const { files } = await discoverConfigFiles(dir);
const cmd = files.find(f => f.type === 'command-md');
assert.ok(cmd, 'should find command markdown');
});
it('classifies SKILL.md', async () => {
const { files } = await discoverConfigFiles(dir);
const skill = files.find(f => f.type === 'skill-md');
assert.ok(skill, 'should find SKILL.md');
});
it('classifies hooks.json in hooks/', async () => {
const { files } = await discoverConfigFiles(dir);
const hooks = files.find(f => f.type === 'hooks-json');
assert.ok(hooks, 'should find hooks.json');
});
it('classifies CLAUDE.local.md', async () => {
const { files } = await discoverConfigFiles(dir);
const local = files.find(f => f.absPath.endsWith('CLAUDE.local.md'));
assert.ok(local, 'should find CLAUDE.local.md');
assert.equal(local.type, 'claude-md');
});
it('does not discover random.txt', async () => {
const { files } = await discoverConfigFiles(dir);
const txt = files.find(f => f.absPath.endsWith('random.txt'));
assert.equal(txt, undefined, 'should not discover .txt files');
});
});
// ───────────────────────────────────────────────────────────────
// Group 3: Depth limit
// ───────────────────────────────────────────────────────────────
describe('depth limit', () => {
let dir;
before(async () => {
dir = tempDir('depth');
// Create structure: a/b/c/d/e/f/g/CLAUDE.md (depth 7 from root)
const shallow = join(dir, 'a', 'b');
const deep = join(dir, 'a', 'b', 'c', 'd', 'e', 'f', 'g');
await mkdir(shallow, { recursive: true });
await mkdir(deep, { recursive: true });
await writeFile(join(shallow, 'CLAUDE.md'), '# Shallow (depth 2)');
await writeFile(join(deep, 'CLAUDE.md'), '# Deep (depth 7)');
});
after(async () => {
await rm(dir, { recursive: true, force: true });
});
it('finds deep files with default maxDepth (10)', async () => {
const { files } = await discoverConfigFiles(dir);
const deep = files.find(f => f.absPath.includes('f/g/CLAUDE.md'));
assert.ok(deep, 'should find CLAUDE.md at depth 7 with default maxDepth');
});
it('respects custom maxDepth: 3', async () => {
const { files } = await discoverConfigFiles(dir, { maxDepth: 3 });
const shallow = files.find(f => f.absPath.includes('a/b/CLAUDE.md'));
const deep = files.find(f => f.absPath.includes('f/g/CLAUDE.md'));
assert.ok(shallow, 'should find shallow CLAUDE.md');
assert.equal(deep, undefined, 'should NOT find deep CLAUDE.md with maxDepth: 3');
});
it('old depth limit of 5 would have missed depth-7 files', async () => {
const { files } = await discoverConfigFiles(dir, { maxDepth: 5 });
const deep = files.find(f => f.absPath.includes('f/g/CLAUDE.md'));
assert.equal(deep, undefined, 'maxDepth: 5 should miss depth-7 files');
});
});
// ───────────────────────────────────────────────────────────────
// Group 4: discoverConfigFilesMulti
// ───────────────────────────────────────────────────────────────
describe('discoverConfigFilesMulti', () => {
let rootA, rootB;
before(async () => {
rootA = tempDir('multiA');
rootB = tempDir('multiB');
await mkdir(rootA, { recursive: true });
await mkdir(rootB, { recursive: true });
await mkdir(join(rootB, 'a', 'b', 'c', 'd'), { recursive: true });
await mkdir(join(rootA, 'node_modules'), { recursive: true });
await writeFile(join(rootA, 'CLAUDE.md'), '# Project A');
await writeFile(join(rootA, '.mcp.json'), '{}');
await writeFile(join(rootB, 'CLAUDE.md'), '# Project B');
await writeFile(join(rootB, 'a', 'b', 'c', 'd', 'CLAUDE.md'), '# Deep B');
// Skippable
await writeFile(join(rootA, 'node_modules', 'CLAUDE.md'), '# Skip');
});
after(async () => {
await rm(rootA, { recursive: true, force: true });
await rm(rootB, { recursive: true, force: true });
});
it('discovers files from both roots', async () => {
const roots = [
{ path: rootA, maxDepth: 10 },
{ path: rootB, maxDepth: 10 },
];
const { files } = await discoverConfigFilesMulti(roots);
const fromA = files.find(f => f.absPath.includes(rootA) && f.type === 'claude-md');
const fromB = files.find(f => f.absPath === join(rootB, 'CLAUDE.md'));
assert.ok(fromA, 'should find CLAUDE.md from root A');
assert.ok(fromB, 'should find CLAUDE.md from root B');
});
it('deduplicates when same root listed twice', async () => {
const roots = [
{ path: rootA, maxDepth: 10 },
{ path: rootA, maxDepth: 10 },
];
const { files } = await discoverConfigFilesMulti(roots);
const claudeFiles = files.filter(f => f.absPath === join(rootA, 'CLAUDE.md'));
assert.equal(claudeFiles.length, 1, 'should deduplicate same file');
});
it('accumulates skipped count across roots', async () => {
const roots = [
{ path: rootA, maxDepth: 10 },
{ path: rootB, maxDepth: 10 },
];
const { skipped } = await discoverConfigFilesMulti(roots);
assert.ok(skipped >= 1, 'should accumulate skipped dirs from rootA');
});
it('respects per-root maxDepth', async () => {
const roots = [
{ path: rootA, maxDepth: 10 },
{ path: rootB, maxDepth: 2 }, // blocks depth-4 file in rootB
];
const { files } = await discoverConfigFilesMulti(roots);
const deepB = files.find(f => f.absPath.includes('c/d/CLAUDE.md'));
assert.equal(deepB, undefined, 'should not find deep file when maxDepth is 2');
});
it('respects global maxFiles cap', async () => {
const roots = [
{ path: rootA, maxDepth: 10 },
{ path: rootB, maxDepth: 10 },
];
const { files } = await discoverConfigFilesMulti(roots, { maxFiles: 1 });
assert.equal(files.length, 1, 'should stop after maxFiles');
});
});
// ───────────────────────────────────────────────────────────────
// Group 5: discoverFullMachinePaths
// ───────────────────────────────────────────────────────────────
describe('discoverFullMachinePaths', () => {
it('returns an array', async () => {
const paths = await discoverFullMachinePaths();
assert.ok(Array.isArray(paths));
});
it('each entry has path (string) and maxDepth (number)', async () => {
const paths = await discoverFullMachinePaths();
for (const entry of paths) {
assert.equal(typeof entry.path, 'string', 'path should be string');
assert.equal(typeof entry.maxDepth, 'number', 'maxDepth should be number');
}
});
it('only returns existing directories', async () => {
const paths = await discoverFullMachinePaths();
for (const entry of paths) {
const s = await stat(entry.path);
assert.ok(s.isDirectory(), `${entry.path} should be a directory`);
}
});
it('includes ~/.claude if it exists', async () => {
const home = process.env.HOME || '';
const paths = await discoverFullMachinePaths();
const hasClaude = paths.some(p => p.path === join(home, '.claude'));
// Only assert if ~/.claude exists on this machine
try {
await stat(join(home, '.claude'));
assert.ok(hasClaude, 'should include ~/.claude');
} catch {
// ~/.claude doesn't exist, skip
}
});
it('has no duplicate paths', async () => {
const paths = await discoverFullMachinePaths();
const seen = new Set();
for (const entry of paths) {
assert.ok(!seen.has(entry.path), `duplicate path: ${entry.path}`);
seen.add(entry.path);
}
});
it('~/.claude gets maxDepth >= 6', async () => {
const home = process.env.HOME || '';
const paths = await discoverFullMachinePaths();
const claude = paths.find(p => p.path === join(home, '.claude'));
if (claude) {
assert.ok(claude.maxDepth >= 6, 'maxDepth for ~/.claude should be >= 6');
}
});
});
// ───────────────────────────────────────────────────────────────
// Group 6: readTextFile
// ───────────────────────────────────────────────────────────────
describe('readTextFile', () => {
let dir;
before(async () => {
dir = tempDir('readtext');
await mkdir(dir, { recursive: true });
await writeFile(join(dir, 'good.md'), '# Hello\nWorld');
await writeFile(join(dir, 'binary.bin'), Buffer.from([0x48, 0x65, 0x00, 0x6c, 0x6f]));
});
after(async () => {
await rm(dir, { recursive: true, force: true });
});
it('returns string for valid UTF-8 file', async () => {
const content = await readTextFile(join(dir, 'good.md'));
assert.equal(typeof content, 'string');
assert.ok(content.includes('Hello'));
});
it('returns null for binary file (null bytes)', async () => {
const content = await readTextFile(join(dir, 'binary.bin'));
assert.equal(content, null);
});
it('returns null for nonexistent file', async () => {
const content = await readTextFile(join(dir, 'nope.txt'));
assert.equal(content, null);
});
});

View file

@ -0,0 +1,149 @@
import { describe, it, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { finding, scannerResult, envelope, resetCounter } from '../../scanners/lib/output.mjs';
describe('resetCounter', () => {
it('resets finding ID counter', () => {
resetCounter();
const f1 = finding({ scanner: 'TST', severity: 'info', title: 'a', description: 'b' });
assert.strictEqual(f1.id, 'CA-TST-001');
resetCounter();
const f2 = finding({ scanner: 'TST', severity: 'info', title: 'c', description: 'd' });
assert.strictEqual(f2.id, 'CA-TST-001');
});
});
describe('finding', () => {
beforeEach(() => { resetCounter(); });
it('generates correct ID format', () => {
const f = finding({ scanner: 'CML', severity: 'high', title: 't', description: 'd' });
assert.match(f.id, /^CA-CML-\d{3}$/);
});
it('auto-increments IDs', () => {
const f1 = finding({ scanner: 'CML', severity: 'info', title: 'a', description: 'b' });
const f2 = finding({ scanner: 'CML', severity: 'info', title: 'c', description: 'd' });
assert.strictEqual(f1.id, 'CA-CML-001');
assert.strictEqual(f2.id, 'CA-CML-002');
});
it('includes all required fields', () => {
const f = finding({
scanner: 'SET',
severity: 'critical',
title: 'Test',
description: 'Desc',
file: '/foo.json',
line: 10,
evidence: 'x=1',
category: 'Structure',
recommendation: 'Fix it',
autoFixable: true,
});
assert.strictEqual(f.scanner, 'SET');
assert.strictEqual(f.severity, 'critical');
assert.strictEqual(f.title, 'Test');
assert.strictEqual(f.description, 'Desc');
assert.strictEqual(f.file, '/foo.json');
assert.strictEqual(f.line, 10);
assert.strictEqual(f.evidence, 'x=1');
assert.strictEqual(f.category, 'Structure');
assert.strictEqual(f.recommendation, 'Fix it');
assert.strictEqual(f.autoFixable, true);
});
it('defaults nullable fields to null', () => {
const f = finding({ scanner: 'TST', severity: 'info', title: 't', description: 'd' });
assert.strictEqual(f.file, null);
assert.strictEqual(f.line, null);
assert.strictEqual(f.evidence, null);
assert.strictEqual(f.category, null);
assert.strictEqual(f.recommendation, null);
assert.strictEqual(f.autoFixable, false);
});
});
describe('scannerResult', () => {
beforeEach(() => { resetCounter(); });
it('counts severity correctly', () => {
const findings = [
finding({ scanner: 'TST', severity: 'critical', title: 'a', description: 'b' }),
finding({ scanner: 'TST', severity: 'high', title: 'c', description: 'd' }),
finding({ scanner: 'TST', severity: 'high', title: 'e', description: 'f' }),
finding({ scanner: 'TST', severity: 'info', title: 'g', description: 'h' }),
];
const r = scannerResult('TST', 'ok', findings, 5, 100);
assert.strictEqual(r.counts.critical, 1);
assert.strictEqual(r.counts.high, 2);
assert.strictEqual(r.counts.medium, 0);
assert.strictEqual(r.counts.low, 0);
assert.strictEqual(r.counts.info, 1);
});
it('includes error message when provided', () => {
const r = scannerResult('TST', 'error', [], 0, 50, 'boom');
assert.strictEqual(r.error, 'boom');
});
it('omits error when not provided', () => {
const r = scannerResult('TST', 'ok', [], 3, 100);
assert.strictEqual(r.error, undefined);
});
it('returns correct structure', () => {
const r = scannerResult('CML', 'ok', [], 2, 42);
assert.strictEqual(r.scanner, 'CML');
assert.strictEqual(r.status, 'ok');
assert.strictEqual(r.files_scanned, 2);
assert.strictEqual(r.duration_ms, 42);
assert.deepStrictEqual(r.findings, []);
});
});
describe('envelope', () => {
beforeEach(() => { resetCounter(); });
it('aggregates across scanners', () => {
const r1 = scannerResult('A', 'ok', [
finding({ scanner: 'A', severity: 'high', title: 'x', description: 'y' }),
], 1, 10);
resetCounter();
const r2 = scannerResult('B', 'ok', [
finding({ scanner: 'B', severity: 'critical', title: 'a', description: 'b' }),
finding({ scanner: 'B', severity: 'low', title: 'c', description: 'd' }),
], 2, 20);
const env = envelope('/target', [r1, r2], 50);
assert.strictEqual(env.aggregate.total_findings, 3);
assert.strictEqual(env.aggregate.counts.critical, 1);
assert.strictEqual(env.aggregate.counts.high, 1);
assert.strictEqual(env.aggregate.counts.low, 1);
assert.strictEqual(env.aggregate.scanners_ok, 2);
});
it('counts scanner statuses', () => {
const r1 = scannerResult('A', 'ok', [], 1, 10);
const r2 = scannerResult('B', 'skipped', [], 0, 5);
const r3 = scannerResult('C', 'error', [], 0, 3, 'fail');
const env = envelope('/t', [r1, r2, r3], 30);
assert.strictEqual(env.aggregate.scanners_ok, 1);
assert.strictEqual(env.aggregate.scanners_skipped, 1);
assert.strictEqual(env.aggregate.scanners_error, 1);
});
it('includes meta with version and tool', () => {
const env = envelope('/t', [], 0);
assert.strictEqual(env.meta.version, '2.2.0');
assert.strictEqual(env.meta.tool, 'config-audit');
assert.strictEqual(env.meta.target, '/t');
assert.ok(env.meta.timestamp);
});
it('calculates verdict correctly', () => {
const env = envelope('/t', [], 0);
assert.strictEqual(env.aggregate.verdict, 'PASS');
});
});

View file

@ -0,0 +1,252 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
generatePostureReport,
generateDriftReport,
generatePluginHealthReport,
generateFullReport,
} from '../../scanners/lib/report-generator.mjs';
// --- Helpers ---
function makePostureResult(overrides = {}) {
return {
utilization: { score: 65, overhang: 35 },
maturity: { level: 2, name: 'Structured', description: 'Rules, skills, hooks' },
segment: { segment: 'Strong', description: 'Well-configured' },
areas: [
{ name: 'CLAUDE.md', grade: 'A', score: 95, findingCount: 0 },
{ name: 'Settings', grade: 'B', score: 80, findingCount: 2 },
{ name: 'Hooks', grade: 'C', score: 60, findingCount: 4 },
],
overallGrade: 'B',
topActions: ['Add MCP server', 'Configure hooks diversity', 'Add custom skills'],
scannerEnvelope: {
meta: { target: '/test/project', timestamp: '2026-04-03T12:00:00.000Z', version: '2.0.0', tool: 'config-audit' },
scanners: [
{ scanner: 'CML', findings: [], counts: { critical: 0, high: 0, medium: 0, low: 0, info: 0 } },
{ scanner: 'SET', findings: [
{ severity: 'medium', title: 'Unknown key', file: 'settings.json' },
{ severity: 'low', title: 'Missing schema', file: 'settings.json' },
], counts: { critical: 0, high: 0, medium: 1, low: 1, info: 0 } },
],
},
...overrides,
};
}
function makeDiffResult(overrides = {}) {
return {
summary: { totalBefore: 10, totalAfter: 8, newCount: 1, resolvedCount: 3, trend: 'improving' },
scoreChange: {
before: { score: 60, grade: 'C' },
after: { score: 75, grade: 'B' },
delta: 15,
},
newFindings: [
{ severity: 'medium', title: 'New finding', file: 'test.json' },
],
resolvedFindings: [
{ severity: 'high', title: 'Fixed issue' },
{ severity: 'medium', title: 'Another fixed' },
{ severity: 'low', title: 'Minor fix' },
],
areaChanges: [
{ name: 'Settings', before: { score: 60, grade: 'C' }, after: { score: 80, grade: 'B' }, delta: 20 },
{ name: 'Hooks', before: { score: 70, grade: 'B' }, after: { score: 70, grade: 'B' }, delta: 0 },
],
...overrides,
};
}
function makePluginResults() {
return [
{ name: 'plugin-a', findings: [], commandCount: 5, agentCount: 2 },
{ name: 'plugin-b', findings: [
{ severity: 'medium', title: 'Missing frontmatter' },
{ severity: 'low', title: 'No README' },
], commandCount: 3, agentCount: 1 },
];
}
function makeScanResult(crossPluginFindings = []) {
return {
scanner: 'PLH',
status: 'ok',
findings: [...crossPluginFindings],
counts: { critical: 0, high: 0, medium: 0, low: 0, info: 0 },
};
}
// ========================================
// generatePostureReport
// ========================================
describe('generatePostureReport', () => {
it('returns markdown with health header and grade', () => {
const report = generatePostureReport(makePostureResult());
assert.ok(report.includes('## Health Assessment'));
assert.ok(report.includes('Health Grade'));
assert.ok(report.includes('**B**'));
});
it('includes area breakdown table (quality areas only)', () => {
const report = generatePostureReport(makePostureResult());
assert.ok(report.includes('| CLAUDE.md |'));
assert.ok(report.includes('| Settings |'));
assert.ok(report.includes('| Hooks |'));
});
it('excludes Feature Coverage from area breakdown', () => {
const result = makePostureResult({
areas: [
{ name: 'CLAUDE.md', grade: 'A', score: 95, findingCount: 0 },
{ name: 'Feature Coverage', grade: 'F', score: 20, findingCount: 15 },
],
});
const report = generatePostureReport(result);
assert.ok(!report.includes('| Feature Coverage |'));
assert.ok(report.includes('| CLAUDE.md |'));
});
it('shows opportunity count when present', () => {
const report = generatePostureReport(makePostureResult({ opportunityCount: 5 }));
assert.ok(report.includes('5 features available'));
});
it('does not show legacy metrics (utilization, maturity, segment)', () => {
const report = generatePostureReport(makePostureResult());
assert.ok(!report.includes('Utilization'));
assert.ok(!report.includes('Maturity'));
assert.ok(!report.includes('Segment'));
});
it('includes scanner findings in collapsed details', () => {
const report = generatePostureReport(makePostureResult());
assert.ok(report.includes('<details>'));
assert.ok(report.includes('SET'));
assert.ok(report.includes('Unknown key'));
});
it('skips scanners with no findings', () => {
const report = generatePostureReport(makePostureResult());
// CML has 0 findings, should not appear in details
assert.ok(!report.includes('<summary>CML'));
});
it('handles empty areas', () => {
const report = generatePostureReport(makePostureResult({ areas: [], topActions: [] }));
assert.ok(report.includes('## Health Assessment'));
});
});
// ========================================
// generateDriftReport
// ========================================
describe('generateDriftReport', () => {
it('returns markdown with baseline info', () => {
const report = generateDriftReport(makeDiffResult(), 'my-baseline');
assert.ok(report.includes('## Drift Report'));
assert.ok(report.includes('my-baseline'));
});
it('shows trend indicator', () => {
const report = generateDriftReport(makeDiffResult(), 'default');
assert.ok(report.includes('Improving'));
});
it('shows score delta', () => {
const report = generateDriftReport(makeDiffResult(), 'default');
assert.ok(report.includes('+15'));
});
it('shows new findings table', () => {
const report = generateDriftReport(makeDiffResult(), 'default');
assert.ok(report.includes('New Findings'));
assert.ok(report.includes('New finding'));
});
it('shows resolved findings table', () => {
const report = generateDriftReport(makeDiffResult(), 'default');
assert.ok(report.includes('Resolved Findings'));
assert.ok(report.includes('Fixed issue'));
});
it('shows area changes (only non-zero delta)', () => {
const report = generateDriftReport(makeDiffResult(), 'default');
assert.ok(report.includes('Settings'));
// Hooks has delta 0, should not appear
assert.ok(!report.includes('| Hooks |'));
});
});
// ========================================
// generatePluginHealthReport
// ========================================
describe('generatePluginHealthReport', () => {
it('returns markdown with plugin table', () => {
const report = generatePluginHealthReport(makeScanResult(), makePluginResults());
assert.ok(report.includes('## Plugin Health'));
assert.ok(report.includes('plugin-a'));
assert.ok(report.includes('plugin-b'));
});
it('shows grades and counts', () => {
const report = generatePluginHealthReport(makeScanResult(), makePluginResults());
assert.ok(report.includes('| 5 |'));
assert.ok(report.includes('| 3 |'));
});
it('includes per-plugin findings', () => {
const report = generatePluginHealthReport(makeScanResult(), makePluginResults());
assert.ok(report.includes('Missing frontmatter'));
});
it('handles no plugins', () => {
const report = generatePluginHealthReport(makeScanResult(), []);
assert.ok(report.includes('No plugins found'));
});
it('shows cross-plugin issues', () => {
const crossFindings = [
{ severity: 'high', title: 'Cross-plugin conflict', description: 'Command name clash' },
];
const report = generatePluginHealthReport(makeScanResult(crossFindings), makePluginResults());
assert.ok(report.includes('Cross-Plugin Issues'));
assert.ok(report.includes('Command name clash'));
});
});
// ========================================
// generateFullReport
// ========================================
describe('generateFullReport', () => {
it('combines all sections', () => {
const report = generateFullReport(
makePostureResult(),
{ diff: makeDiffResult(), baselineName: 'default' },
{ scanResult: makeScanResult(), pluginResults: makePluginResults() },
);
assert.ok(report.includes('# Config-Audit Report'));
assert.ok(report.includes('## Health Assessment'));
assert.ok(report.includes('## Drift Report'));
assert.ok(report.includes('## Plugin Health'));
});
it('skips null sections', () => {
const report = generateFullReport(makePostureResult(), null, null);
assert.ok(report.includes('## Health Assessment'));
assert.ok(!report.includes('## Drift Report'));
assert.ok(!report.includes('## Plugin Health'));
});
it('handles all null inputs', () => {
const report = generateFullReport(null, null, null);
assert.ok(report.includes('No data provided'));
});
it('stays under 500 lines', () => {
const report = generateFullReport(makePostureResult(), null, null);
const lineCount = report.split('\n').length;
assert.ok(lineCount <= 502); // 500 + truncation notice
});
});

View file

@ -0,0 +1,545 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
calculateUtilization,
determineMaturityLevel,
determineSegment,
scoreByArea,
topActions,
generateScorecard,
generateHealthScorecard,
TITLE_TO_ID,
TIER_WEIGHTS,
TIER_COUNTS,
MAX_WEIGHTED,
MATURITY_LEVELS,
SEGMENTS,
} from '../../scanners/lib/scoring.mjs';
// --- Helpers ---
function makeGapFinding(title, tier) {
return { id: 'CA-GAP-999', scanner: 'GAP', severity: 'info', title, category: tier, recommendation: 'Fix it' };
}
function allGapFindings() {
return Object.entries(TITLE_TO_ID).map(([title, id]) => {
const tier = id.split('_')[0];
return makeGapFinding(title, tier);
});
}
function t1GapFindings() {
return Object.entries(TITLE_TO_ID)
.filter(([, id]) => id.startsWith('t1'))
.map(([title]) => makeGapFinding(title, 't1'));
}
function t4GapFindings() {
return Object.entries(TITLE_TO_ID)
.filter(([, id]) => id.startsWith('t4'))
.map(([title]) => makeGapFinding(title, 't4'));
}
function makeScannerResult(scanner, findingCount) {
const findings = Array.from({ length: findingCount }, (_, i) => ({
id: `CA-${scanner}-${String(i + 1).padStart(3, '0')}`,
scanner,
severity: 'low',
title: `Finding ${i + 1}`,
category: scanner === 'GAP' ? 't2' : null,
recommendation: 'Fix',
}));
return {
scanner,
status: 'ok',
files_scanned: 5,
duration_ms: 10,
findings,
counts: { critical: 0, high: 0, medium: 0, low: findingCount, info: 0 },
};
}
// ========================================
// calculateUtilization
// ========================================
describe('calculateUtilization', () => {
it('returns 100% with no gap findings', () => {
const result = calculateUtilization([]);
assert.equal(result.score, 100);
assert.equal(result.overhang, 0);
});
it('returns 0% when all 25 dimensions are gaps', () => {
const result = calculateUtilization(allGapFindings());
assert.equal(result.score, 0);
assert.equal(result.overhang, 100);
});
it('weighs T1 gaps heavier (3x)', () => {
const onlyT1 = t1GapFindings(); // 5 T1 gaps = 15 weight lost
const result = calculateUtilization(onlyT1);
// Lost: 5 × 3 = 15 out of 42. Present: 27/42 = 64%
assert.equal(result.score, 64);
});
it('weighs T4 gaps lighter (1x)', () => {
const onlyT4 = t4GapFindings(); // 5 T4 gaps = 5 weight lost
const result = calculateUtilization(onlyT4);
// Lost: 5 × 1 = 5 out of 42. Present: 37/42 = 88%
assert.equal(result.score, 88);
});
it('T1+T2 present but no T3+T4 scores ~69%', () => {
// T3: 8 dims × 1 = 8, T4: 5 dims × 1 = 5. Lost = 13 out of 42. Present = 29/42 = 69%
const t3t4Gaps = Object.entries(TITLE_TO_ID)
.filter(([, id]) => id.startsWith('t3') || id.startsWith('t4'))
.map(([title, id]) => makeGapFinding(title, id.split('_')[0]));
const result = calculateUtilization(t3t4Gaps);
assert.equal(result.score, 69);
});
it('score + overhang = 100', () => {
const result = calculateUtilization(t1GapFindings());
assert.equal(result.score + result.overhang, 100);
});
it('handles empty array', () => {
const result = calculateUtilization([]);
assert.equal(typeof result.score, 'number');
assert.equal(typeof result.overhang, 'number');
});
it('ignores findings with unknown category', () => {
const weird = [{ category: 'tx' }, { category: undefined }];
const result = calculateUtilization(weird);
assert.equal(result.score, 100); // unknown tiers don't count
});
});
// ========================================
// determineMaturityLevel
// ========================================
describe('determineMaturityLevel', () => {
const discovery = { files: [] };
it('returns Level 0 when CLAUDE.md is missing', () => {
const gaps = [makeGapFinding('No CLAUDE.md file', 't1')];
const result = determineMaturityLevel(gaps, discovery);
assert.equal(result.level, 0);
assert.equal(result.name, 'Bare');
});
it('returns Level 1 when CLAUDE.md present but no permissions', () => {
const gaps = [
makeGapFinding('No permissions configured', 't1'),
makeGapFinding('No hooks configured', 't1'),
];
const result = determineMaturityLevel(gaps, discovery);
assert.equal(result.level, 1);
});
it('returns Level 2 when permissions + hooks + modular present but no MCP', () => {
const gaps = [
makeGapFinding('No MCP servers configured', 't1'),
makeGapFinding('Low hook diversity', 't2'),
makeGapFinding('No custom subagents', 't2'),
];
const result = determineMaturityLevel(gaps, discovery);
assert.equal(result.level, 2);
assert.equal(result.name, 'Structured');
});
it('returns Level 3 when MCP + hook diversity + subagents present but no plugin', () => {
const gaps = [
makeGapFinding('No custom plugin', 't4'),
];
const result = determineMaturityLevel(gaps, discovery);
assert.equal(result.level, 3);
assert.equal(result.name, 'Automated');
});
it('returns Level 4 when all requirements met', () => {
const result = determineMaturityLevel([], discovery);
assert.equal(result.level, 4);
assert.equal(result.name, 'Governed');
});
it('Level 2 requires modular OR path-rules (modular)', () => {
// Has permissions, hooks, modular — but no path-rules. Should still be level 2.
const gaps = [
makeGapFinding('No path-scoped rules', 't2'),
makeGapFinding('No MCP servers configured', 't1'),
makeGapFinding('Low hook diversity', 't2'),
makeGapFinding('No custom subagents', 't2'),
];
const result = determineMaturityLevel(gaps, discovery);
assert.equal(result.level, 2);
});
it('Level 2 requires modular OR path-rules (path-rules)', () => {
// Has permissions, hooks, path-rules — but not modular. Should still be level 2.
const gaps = [
makeGapFinding('CLAUDE.md not modular', 't2'),
makeGapFinding('No MCP servers configured', 't1'),
makeGapFinding('Low hook diversity', 't2'),
makeGapFinding('No custom subagents', 't2'),
];
const result = determineMaturityLevel(gaps, discovery);
assert.equal(result.level, 2);
});
it('stays Level 1 when neither modular nor path-rules', () => {
const gaps = [
makeGapFinding('CLAUDE.md not modular', 't2'),
makeGapFinding('No path-scoped rules', 't2'),
];
const result = determineMaturityLevel(gaps, discovery);
assert.equal(result.level, 1);
});
it('Level 3 blocked by missing hook diversity', () => {
const gaps = [
makeGapFinding('Low hook diversity', 't2'),
];
const result = determineMaturityLevel(gaps, discovery);
assert.equal(result.level, 2);
});
it('Level 4 blocked by missing project MCP in git', () => {
const gaps = [
makeGapFinding('No project .mcp.json in git', 't4'),
];
const result = determineMaturityLevel(gaps, discovery);
assert.equal(result.level, 3);
});
it('all MATURITY_LEVELS have required fields', () => {
for (const ml of MATURITY_LEVELS) {
assert.ok(typeof ml.level === 'number');
assert.ok(typeof ml.name === 'string');
assert.ok(typeof ml.description === 'string');
}
});
});
// ========================================
// determineSegment
// ========================================
describe('determineSegment', () => {
it('Top Performer at score > 80', () => {
assert.equal(determineSegment(81).segment, 'Top Performer');
assert.equal(determineSegment(100).segment, 'Top Performer');
});
it('Strong at 65-80', () => {
assert.equal(determineSegment(65).segment, 'Strong');
assert.equal(determineSegment(80).segment, 'Strong');
});
it('Competent at 45-64', () => {
assert.equal(determineSegment(45).segment, 'Competent');
assert.equal(determineSegment(64).segment, 'Competent');
});
it('Developing at 25-44', () => {
assert.equal(determineSegment(25).segment, 'Developing');
assert.equal(determineSegment(44).segment, 'Developing');
});
it('Beginner at < 25', () => {
assert.equal(determineSegment(0).segment, 'Beginner');
assert.equal(determineSegment(24).segment, 'Beginner');
});
it('returns description string', () => {
const result = determineSegment(50);
assert.ok(result.description.length > 0);
});
it('edge case: exactly 80 is Strong', () => {
assert.equal(determineSegment(80).segment, 'Strong');
});
});
// ========================================
// scoreByArea
// ========================================
describe('scoreByArea', () => {
it('returns areas for all 8 scanners', () => {
const scanners = ['CML', 'SET', 'HKV', 'RUL', 'MCP', 'IMP', 'CNF', 'GAP']
.map(s => makeScannerResult(s, 0));
const result = scoreByArea(scanners);
assert.equal(result.areas.length, 8);
});
it('zero findings → A grade', () => {
const scanners = [makeScannerResult('CML', 0)];
const result = scoreByArea(scanners);
assert.equal(result.areas[0].grade, 'A');
assert.equal(result.areas[0].score, 100);
});
it('many findings → lower grade', () => {
const scanners = [makeScannerResult('CML', 8)];
const result = scoreByArea(scanners);
assert.ok(result.areas[0].score < 50);
});
it('GAP scanner uses utilization-based scoring', () => {
const gapResult = makeScannerResult('GAP', 0);
const result = scoreByArea([gapResult]);
assert.equal(result.areas[0].name, 'Feature Coverage');
assert.equal(result.areas[0].score, 100); // 0 gaps = 100%
});
it('overall grade is average of quality areas (excludes GAP)', () => {
const scanners = [
makeScannerResult('CML', 0), // 100
makeScannerResult('SET', 0), // 100
makeScannerResult('GAP', 20), // low utilization — should NOT drag down grade
];
const result = scoreByArea(scanners);
assert.equal(result.overallGrade, 'A'); // only CML+SET averaged
assert.equal(result.areas.length, 3); // GAP still in areas for display
});
it('mixed grades produce mixed overall', () => {
const scanners = [
makeScannerResult('CML', 0), // 100 → A
makeScannerResult('SET', 10), // low → F range
];
const result = scoreByArea(scanners);
assert.ok(['A', 'B', 'C', 'D', 'F'].includes(result.overallGrade));
});
it('empty scanner array → overallGrade F', () => {
const result = scoreByArea([]);
assert.equal(result.areas.length, 0);
assert.equal(result.overallGrade, 'F');
});
it('area objects have required fields', () => {
const scanners = [makeScannerResult('CML', 2)];
const result = scoreByArea(scanners);
const area = result.areas[0];
assert.ok('name' in area);
assert.ok('grade' in area);
assert.ok('score' in area);
assert.ok('findingCount' in area);
});
});
// ========================================
// topActions
// ========================================
describe('topActions', () => {
it('returns max 3 actions', () => {
const gaps = allGapFindings();
const result = topActions(gaps);
assert.equal(result.length, 3);
});
it('prioritizes T1 over T2', () => {
const gaps = [
{ ...makeGapFinding('Low hook diversity', 't2'), recommendation: 'Add hooks' },
{ ...makeGapFinding('No CLAUDE.md file', 't1'), recommendation: 'Create CLAUDE.md' },
];
const result = topActions(gaps);
assert.equal(result[0], 'Create CLAUDE.md');
});
it('returns empty array for no gaps', () => {
assert.deepEqual(topActions([]), []);
});
it('returns all items if fewer than 3', () => {
const gaps = [makeGapFinding('No CLAUDE.md file', 't1')];
assert.equal(topActions(gaps).length, 1);
});
});
// ========================================
// generateScorecard
// ========================================
describe('generateScorecard', () => {
const sampleAreas = {
areas: [
{ name: 'CLAUDE.md', grade: 'A', score: 92 },
{ name: 'Settings', grade: 'B', score: 78 },
{ name: 'Hooks', grade: 'C', score: 55 },
{ name: 'Rules', grade: 'B', score: 71 },
],
overallGrade: 'B',
};
const sampleUtil = { score: 68, overhang: 32 };
const sampleMaturity = { level: 2, name: 'Structured' };
const sampleSegment = { segment: 'Strong' };
const sampleActions = ['Configure MCP', 'Add hooks', 'Create agents'];
it('returns a string', () => {
const result = generateScorecard(sampleAreas, sampleUtil, sampleMaturity, sampleSegment, sampleActions);
assert.equal(typeof result, 'string');
});
it('contains header line', () => {
const result = generateScorecard(sampleAreas, sampleUtil, sampleMaturity, sampleSegment, sampleActions);
assert.ok(result.includes('Config-Audit Posture Score'));
});
it('contains overall grade', () => {
const result = generateScorecard(sampleAreas, sampleUtil, sampleMaturity, sampleSegment, sampleActions);
assert.ok(result.includes('Overall: B'));
});
it('contains maturity level', () => {
const result = generateScorecard(sampleAreas, sampleUtil, sampleMaturity, sampleSegment, sampleActions);
assert.ok(result.includes('Level 2 (Structured)'));
});
it('contains utilization', () => {
const result = generateScorecard(sampleAreas, sampleUtil, sampleMaturity, sampleSegment, sampleActions);
assert.ok(result.includes('Utilization: 68%'));
});
it('contains segment', () => {
const result = generateScorecard(sampleAreas, sampleUtil, sampleMaturity, sampleSegment, sampleActions);
assert.ok(result.includes('Segment: Strong'));
});
it('contains all area names', () => {
const result = generateScorecard(sampleAreas, sampleUtil, sampleMaturity, sampleSegment, sampleActions);
assert.ok(result.includes('CLAUDE.md'));
assert.ok(result.includes('Settings'));
assert.ok(result.includes('Hooks'));
assert.ok(result.includes('Rules'));
});
it('contains top actions', () => {
const result = generateScorecard(sampleAreas, sampleUtil, sampleMaturity, sampleSegment, sampleActions);
assert.ok(result.includes('1. Configure MCP'));
assert.ok(result.includes('2. Add hooks'));
assert.ok(result.includes('3. Create agents'));
});
it('works with empty areas', () => {
const result = generateScorecard({ areas: [], overallGrade: 'F' }, sampleUtil, sampleMaturity, sampleSegment, []);
assert.equal(typeof result, 'string');
assert.ok(result.includes('Config-Audit Posture Score'));
});
it('works with odd number of areas', () => {
const odd = { areas: [{ name: 'Test', grade: 'A', score: 95 }], overallGrade: 'A' };
const result = generateScorecard(odd, sampleUtil, sampleMaturity, sampleSegment, []);
assert.ok(result.includes('Test'));
});
});
// ========================================
// generateHealthScorecard (v3)
// ========================================
describe('generateHealthScorecard', () => {
const sampleAreas = {
areas: [
{ name: 'CLAUDE.md', grade: 'A', score: 92 },
{ name: 'Settings', grade: 'B', score: 78 },
{ name: 'Hooks', grade: 'C', score: 55 },
{ name: 'Feature Coverage', grade: 'F', score: 20 },
],
overallGrade: 'B',
};
it('returns a string', () => {
const result = generateHealthScorecard(sampleAreas, 12);
assert.equal(typeof result, 'string');
});
it('contains Health header (not Overall)', () => {
const result = generateHealthScorecard(sampleAreas, 5);
assert.ok(result.includes('Config-Audit Health Score'));
assert.ok(result.includes('Health: B'));
assert.ok(!result.includes('Overall:'));
});
it('does NOT contain Maturity, Utilization, or Segment', () => {
const result = generateHealthScorecard(sampleAreas, 5);
assert.ok(!result.includes('Maturity:'));
assert.ok(!result.includes('Utilization:'));
assert.ok(!result.includes('Segment:'));
});
it('excludes Feature Coverage from area display', () => {
const result = generateHealthScorecard(sampleAreas, 5);
assert.ok(!result.includes('Feature Coverage'));
assert.ok(result.includes('CLAUDE.md'));
assert.ok(result.includes('Settings'));
assert.ok(result.includes('Hooks'));
});
it('shows opportunity count', () => {
const result = generateHealthScorecard(sampleAreas, 12);
assert.ok(result.includes('12 opportunities available'));
});
it('uses singular for 1 opportunity', () => {
const result = generateHealthScorecard(sampleAreas, 1);
assert.ok(result.includes('1 opportunity available'));
});
it('hides opportunity line when count is 0', () => {
const result = generateHealthScorecard(sampleAreas, 0);
assert.ok(!result.includes('opportunit'));
});
it('shows areas scanned count', () => {
const result = generateHealthScorecard(sampleAreas, 5);
assert.ok(result.includes('3 areas scanned')); // 3 quality areas (excl Feature Coverage)
});
it('computes avgScore from quality areas only', () => {
// Quality areas: A(92), B(78), C(55) → avg = 75
const result = generateHealthScorecard(sampleAreas, 5);
assert.ok(result.includes('(75/100)'));
});
it('works with empty areas', () => {
const result = generateHealthScorecard({ areas: [], overallGrade: 'F' }, 0);
assert.equal(typeof result, 'string');
assert.ok(result.includes('Config-Audit Health Score'));
});
});
// ========================================
// Constants and exports
// ========================================
describe('scoring constants', () => {
it('TITLE_TO_ID has 25 entries', () => {
assert.equal(Object.keys(TITLE_TO_ID).length, 25);
});
it('TIER_COUNTS sum to 25', () => {
const sum = Object.values(TIER_COUNTS).reduce((a, b) => a + b, 0);
assert.equal(sum, 25);
});
it('MAX_WEIGHTED is 42', () => {
assert.equal(MAX_WEIGHTED, 42);
});
it('TIER_WEIGHTS match spec', () => {
assert.equal(TIER_WEIGHTS.t1, 3);
assert.equal(TIER_WEIGHTS.t2, 2);
assert.equal(TIER_WEIGHTS.t3, 1);
assert.equal(TIER_WEIGHTS.t4, 1);
});
it('SEGMENTS covers full 0-100 range', () => {
assert.equal(SEGMENTS[SEGMENTS.length - 1].min, 0);
assert.ok(SEGMENTS[0].min >= 80);
});
it('MATURITY_LEVELS has 5 levels (0-4)', () => {
assert.equal(MATURITY_LEVELS.length, 5);
assert.equal(MATURITY_LEVELS[0].level, 0);
assert.equal(MATURITY_LEVELS[4].level, 4);
});
});

View file

@ -0,0 +1,133 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { SEVERITY, riskScore, verdict, riskBand, gradeFromPassRate, QUALITY_CATEGORIES } from '../../scanners/lib/severity.mjs';
describe('SEVERITY constants', () => {
it('has all 5 levels', () => {
assert.deepStrictEqual(Object.keys(SEVERITY).sort(), ['critical', 'high', 'info', 'low', 'medium']);
});
it('is frozen', () => {
assert.throws(() => { SEVERITY.critical = 'x'; }, TypeError);
});
});
describe('riskScore', () => {
it('returns 0 for empty counts', () => {
assert.strictEqual(riskScore({}), 0);
});
it('returns 0 for info-only findings', () => {
assert.strictEqual(riskScore({ info: 10 }), 0);
});
it('scores low findings at 1 point each', () => {
assert.strictEqual(riskScore({ low: 5 }), 5);
});
it('scores medium findings at 4 points each', () => {
assert.strictEqual(riskScore({ medium: 3 }), 12);
});
it('scores high findings at 10 points each', () => {
assert.strictEqual(riskScore({ high: 2 }), 20);
});
it('scores critical findings at 25 points each', () => {
assert.strictEqual(riskScore({ critical: 1 }), 25);
});
it('caps at 100', () => {
assert.strictEqual(riskScore({ critical: 10 }), 100);
});
it('combines all severities', () => {
assert.strictEqual(riskScore({ critical: 1, high: 1, medium: 1, low: 1, info: 1 }), 40);
});
});
describe('verdict', () => {
it('returns PASS for no findings', () => {
assert.strictEqual(verdict({}), 'PASS');
});
it('returns PASS for low findings only', () => {
assert.strictEqual(verdict({ low: 5, info: 10 }), 'PASS');
});
it('returns WARNING for any high finding', () => {
assert.strictEqual(verdict({ high: 1 }), 'WARNING');
});
it('returns FAIL for any critical finding', () => {
assert.strictEqual(verdict({ critical: 1 }), 'FAIL');
});
it('returns FAIL for score >= 61', () => {
assert.strictEqual(verdict({ high: 6, medium: 1 }), 'FAIL');
});
it('returns WARNING for score >= 21', () => {
assert.strictEqual(verdict({ medium: 6 }), 'WARNING');
});
});
describe('riskBand', () => {
it('returns Low for score 0', () => {
assert.strictEqual(riskBand(0), 'Low');
});
it('returns Low for score 10', () => {
assert.strictEqual(riskBand(10), 'Low');
});
it('returns Medium for score 11-30', () => {
assert.strictEqual(riskBand(20), 'Medium');
});
it('returns High for score 31-60', () => {
assert.strictEqual(riskBand(50), 'High');
});
it('returns Critical for score 61-80', () => {
assert.strictEqual(riskBand(70), 'Critical');
});
it('returns Extreme for score > 80', () => {
assert.strictEqual(riskBand(90), 'Extreme');
});
});
describe('gradeFromPassRate', () => {
it('returns A for 90+', () => {
assert.strictEqual(gradeFromPassRate(95), 'A');
});
it('returns B for 75-89', () => {
assert.strictEqual(gradeFromPassRate(80), 'B');
});
it('returns C for 60-74', () => {
assert.strictEqual(gradeFromPassRate(65), 'C');
});
it('returns D for 40-59', () => {
assert.strictEqual(gradeFromPassRate(50), 'D');
});
it('returns F for below 40', () => {
assert.strictEqual(gradeFromPassRate(20), 'F');
});
});
describe('QUALITY_CATEGORIES', () => {
it('has expected categories', () => {
assert.ok(QUALITY_CATEGORIES.STRUCTURE);
assert.ok(QUALITY_CATEGORIES.FEATURES);
assert.ok(QUALITY_CATEGORIES.SECURITY);
});
it('is frozen', () => {
assert.throws(() => { QUALITY_CATEGORIES.NEW = 'x'; }, TypeError);
});
});

View file

@ -0,0 +1,116 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { lineCount, truncate, isSimilar, extractKeys, normalizePath } from '../../scanners/lib/string-utils.mjs';
describe('lineCount', () => {
it('counts lines correctly', () => {
assert.strictEqual(lineCount('a\nb\nc'), 3);
});
it('returns 0 for empty/null input', () => {
assert.strictEqual(lineCount(''), 0);
assert.strictEqual(lineCount(null), 0);
assert.strictEqual(lineCount(undefined), 0);
});
it('counts single line', () => {
assert.strictEqual(lineCount('hello'), 1);
});
});
describe('truncate', () => {
it('returns short strings unchanged', () => {
assert.strictEqual(truncate('hello', 10), 'hello');
});
it('truncates long strings with ellipsis', () => {
const result = truncate('a very long string that needs truncating', 20);
assert.strictEqual(result.length, 20);
assert.ok(result.endsWith('...'));
});
it('handles empty/null input', () => {
assert.strictEqual(truncate(''), '');
assert.strictEqual(truncate(null), '');
assert.strictEqual(truncate(undefined), '');
});
it('uses default maxLen of 100', () => {
const long = 'x'.repeat(200);
assert.strictEqual(truncate(long).length, 100);
});
});
describe('isSimilar', () => {
it('returns true for identical strings', () => {
assert.ok(isSimilar('hello world foo bar', 'hello world foo bar'));
});
it('returns true for highly similar strings', () => {
assert.ok(isSimilar(
'use typescript for all code in this project',
'use typescript for all code in this repository'
));
});
it('returns false for dissimilar strings', () => {
assert.ok(!isSimilar('hello world', 'goodbye universe'));
});
it('returns false for empty strings', () => {
assert.ok(!isSimilar('', ''));
});
it('ignores short words', () => {
assert.ok(!isSimilar('a b c d', 'a b c d'));
});
});
describe('extractKeys', () => {
it('extracts top-level keys', () => {
const keys = extractKeys({ a: 1, b: 2 });
assert.deepStrictEqual(keys, ['a', 'b']);
});
it('extracts nested keys with dot notation', () => {
const keys = extractKeys({ a: { b: { c: 1 } } });
assert.ok(keys.includes('a'));
assert.ok(keys.includes('a.b'));
assert.ok(keys.includes('a.b.c'));
});
it('handles arrays as leaf values', () => {
const keys = extractKeys({ list: [1, 2, 3] });
assert.deepStrictEqual(keys, ['list']);
});
it('uses prefix', () => {
const keys = extractKeys({ a: 1 }, 'root');
assert.deepStrictEqual(keys, ['root.a']);
});
});
describe('normalizePath', () => {
it('expands ~ to HOME', () => {
const home = process.env.HOME;
assert.strictEqual(normalizePath('~/foo'), `${home}/foo`);
});
it('strips trailing slashes', () => {
assert.ok(!normalizePath('/foo/bar/').endsWith('/'));
});
it('strips trailing backslashes (Windows paths)', () => {
const result = normalizePath('C:\\Users\\foo\\');
assert.ok(!result.endsWith('\\'), 'trailing backslash should be stripped');
});
it('strips multiple trailing backslashes', () => {
const result = normalizePath('C:\\foo\\\\');
assert.ok(!result.endsWith('\\'));
});
it('handles absolute paths', () => {
assert.strictEqual(normalizePath('/usr/bin'), '/usr/bin');
});
});

View file

@ -0,0 +1,199 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { join } from 'node:path';
import { writeFile, mkdir, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import {
loadSuppressions,
parseIgnoreFile,
applySuppressions,
formatSuppressionSummary,
} from '../../scanners/lib/suppression.mjs';
// --- Helpers ---
function makeFinding(id, scanner, severity = 'medium') {
return {
id,
scanner,
severity,
title: `Finding ${id}`,
description: `Description for ${id}`,
file: null,
line: null,
evidence: null,
category: null,
recommendation: null,
autoFixable: false,
};
}
// ========================================
// parseIgnoreFile
// ========================================
describe('parseIgnoreFile', () => {
it('parses exact finding IDs', () => {
const result = parseIgnoreFile('CA-CML-001\nCA-SET-003');
assert.equal(result.length, 2);
assert.equal(result[0].pattern, 'CA-CML-001');
assert.equal(result[1].pattern, 'CA-SET-003');
});
it('parses glob patterns', () => {
const result = parseIgnoreFile('CA-GAP-*');
assert.equal(result.length, 1);
assert.equal(result[0].pattern, 'CA-GAP-*');
});
it('skips comments and empty lines', () => {
const content = `# This is a comment
CA-CML-001
# Another comment
CA-SET-002`;
const result = parseIgnoreFile(content);
assert.equal(result.length, 2);
});
it('extracts inline comments', () => {
const result = parseIgnoreFile('CA-HKV-003 # Known timeout in CI');
assert.equal(result.length, 1);
assert.equal(result[0].pattern, 'CA-HKV-003');
assert.equal(result[0].comment, 'Known timeout in CI');
});
it('returns empty array for empty content', () => {
const result = parseIgnoreFile('');
assert.deepEqual(result, []);
});
it('returns empty array for comment-only content', () => {
const result = parseIgnoreFile('# Just comments\n# Nothing else');
assert.deepEqual(result, []);
});
});
// ========================================
// applySuppressions
// ========================================
describe('applySuppressions', () => {
it('filters exact match', () => {
const findings = [
makeFinding('CA-CML-001', 'CML'),
makeFinding('CA-CML-002', 'CML'),
];
const suppressions = [{ pattern: 'CA-CML-001', comment: '' }];
const { active, suppressed } = applySuppressions(findings, suppressions);
assert.equal(active.length, 1);
assert.equal(active[0].id, 'CA-CML-002');
assert.equal(suppressed.length, 1);
assert.equal(suppressed[0].id, 'CA-CML-001');
});
it('filters glob pattern CA-SET-*', () => {
const findings = [
makeFinding('CA-SET-001', 'SET'),
makeFinding('CA-SET-002', 'SET'),
makeFinding('CA-CML-001', 'CML'),
];
const suppressions = [{ pattern: 'CA-SET-*', comment: '' }];
const { active, suppressed } = applySuppressions(findings, suppressions);
assert.equal(active.length, 1);
assert.equal(active[0].id, 'CA-CML-001');
assert.equal(suppressed.length, 2);
});
it('returns all active when no suppressions', () => {
const findings = [makeFinding('CA-CML-001', 'CML')];
const { active, suppressed } = applySuppressions(findings, []);
assert.equal(active.length, 1);
assert.equal(suppressed.length, 0);
});
it('returns all active when suppressions is null', () => {
const findings = [makeFinding('CA-CML-001', 'CML')];
const { active, suppressed } = applySuppressions(findings, null);
assert.equal(active.length, 1);
assert.equal(suppressed.length, 0);
});
it('handles empty findings list', () => {
const suppressions = [{ pattern: 'CA-CML-*', comment: '' }];
const { active, suppressed } = applySuppressions([], suppressions);
assert.equal(active.length, 0);
assert.equal(suppressed.length, 0);
});
it('applies multiple suppression patterns', () => {
const findings = [
makeFinding('CA-CML-001', 'CML'),
makeFinding('CA-SET-001', 'SET'),
makeFinding('CA-GAP-001', 'GAP'),
];
const suppressions = [
{ pattern: 'CA-CML-001', comment: '' },
{ pattern: 'CA-GAP-*', comment: '' },
];
const { active, suppressed } = applySuppressions(findings, suppressions);
assert.equal(active.length, 1);
assert.equal(active[0].id, 'CA-SET-001');
assert.equal(suppressed.length, 2);
});
});
// ========================================
// formatSuppressionSummary
// ========================================
describe('formatSuppressionSummary', () => {
it('formats correct count and groups', () => {
const suppressed = [
makeFinding('CA-GAP-001', 'GAP'),
makeFinding('CA-GAP-002', 'GAP'),
makeFinding('CA-HKV-003', 'HKV'),
];
const summary = formatSuppressionSummary(suppressed);
assert.ok(summary.includes('3 finding(s) suppressed'));
assert.ok(summary.includes('CA-GAP-*'));
assert.ok(summary.includes('CA-HKV-*'));
});
it('returns zero message for empty array', () => {
assert.equal(formatSuppressionSummary([]), '0 findings suppressed');
});
it('returns zero message for null', () => {
assert.equal(formatSuppressionSummary(null), '0 findings suppressed');
});
});
// ========================================
// loadSuppressions
// ========================================
describe('loadSuppressions', () => {
const tmpDir = join(tmpdir(), `config-audit-suppress-test-${Date.now()}`);
it('returns empty when no .config-audit-ignore exists', async () => {
const result = await loadSuppressions('/nonexistent/path');
assert.deepEqual(result.suppressions, []);
assert.equal(result.source, 'none');
});
it('loads from project directory', async () => {
await mkdir(tmpDir, { recursive: true });
await writeFile(join(tmpDir, '.config-audit-ignore'), 'CA-GAP-*\nCA-CML-001\n');
const result = await loadSuppressions(tmpDir);
assert.equal(result.suppressions.length, 2);
assert.equal(result.source, 'project');
await rm(tmpDir, { recursive: true, force: true });
});
});

View file

@ -0,0 +1,147 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { parseFrontmatter, parseSimpleYaml, parseJson, findImports, extractSections } from '../../scanners/lib/yaml-parser.mjs';
describe('parseFrontmatter', () => {
it('parses standard frontmatter', () => {
const content = '---\nname: test\nmodel: opus\n---\n\nBody here';
const { frontmatter, body } = parseFrontmatter(content);
assert.deepStrictEqual(frontmatter, { name: 'test', model: 'opus' });
assert.ok(body.includes('Body here'));
});
it('returns null frontmatter when none exists', () => {
const { frontmatter, body } = parseFrontmatter('Just body text');
assert.strictEqual(frontmatter, null);
assert.strictEqual(body, 'Just body text');
});
it('handles empty frontmatter', () => {
const { frontmatter } = parseFrontmatter('---\n---\nBody');
assert.deepStrictEqual(frontmatter, {});
});
it('calculates bodyStartLine correctly', () => {
const content = '---\na: 1\nb: 2\n---\nBody';
const { bodyStartLine } = parseFrontmatter(content);
assert.strictEqual(bodyStartLine, 5);
});
});
describe('parseSimpleYaml', () => {
it('parses key-value pairs', () => {
const result = parseSimpleYaml('name: test\nmodel: opus');
assert.strictEqual(result.name, 'test');
assert.strictEqual(result.model, 'opus');
});
it('parses boolean values', () => {
const result = parseSimpleYaml('enabled: true\ndisabled: false');
assert.strictEqual(result.enabled, true);
assert.strictEqual(result.disabled, false);
});
it('parses numeric values', () => {
const result = parseSimpleYaml('count: 42\nrate: 3.14');
assert.strictEqual(result.count, 42);
assert.strictEqual(result.rate, 3.14);
});
it('parses inline arrays', () => {
const result = parseSimpleYaml('tools: [Read, Write, Bash]');
assert.deepStrictEqual(result.tools, ['Read', 'Write', 'Bash']);
});
it('strips quotes from values', () => {
const result = parseSimpleYaml('name: "quoted value"');
assert.strictEqual(result.name, 'quoted value');
});
it('normalizes hyphens to underscores in keys', () => {
const result = parseSimpleYaml('allowed-tools: Read');
assert.ok('allowed_tools' in result);
});
it('normalizes comma-separated strings in list fields', () => {
const result = parseSimpleYaml('allowed-tools: Read, Write, Bash');
assert.deepStrictEqual(result.allowed_tools, ['Read', 'Write', 'Bash']);
});
it('handles null values', () => {
const result = parseSimpleYaml('value: null\ntilde: ~\nempty:');
assert.strictEqual(result.value, null);
assert.strictEqual(result.tilde, null);
assert.strictEqual(result.empty, null);
});
it('skips comments', () => {
const result = parseSimpleYaml('# comment\nname: test\n# another');
assert.strictEqual(result.name, 'test');
assert.strictEqual(Object.keys(result).length, 1);
});
it('handles multi-line pipe values', () => {
const result = parseSimpleYaml('description: |\n Line 1\n Line 2\nname: test');
assert.ok(result.description.includes('Line 1'));
assert.ok(result.description.includes('Line 2'));
assert.strictEqual(result.name, 'test');
});
});
describe('parseJson', () => {
it('parses valid JSON', () => {
const result = parseJson('{"key": "value"}');
assert.deepStrictEqual(result, { key: 'value' });
});
it('returns null for invalid JSON', () => {
assert.strictEqual(parseJson('{invalid}'), null);
});
it('returns null for empty string', () => {
assert.strictEqual(parseJson(''), null);
});
});
describe('findImports', () => {
it('finds @import lines', () => {
const content = '# Title\n@path/to/file.md\nSome text\n@another/file.md';
const imports = findImports(content);
assert.strictEqual(imports.length, 2);
assert.strictEqual(imports[0].path, 'path/to/file.md');
assert.strictEqual(imports[0].line, 2);
assert.strictEqual(imports[1].path, 'another/file.md');
assert.strictEqual(imports[1].line, 4);
});
it('returns empty array when no imports', () => {
assert.deepStrictEqual(findImports('Just text'), []);
});
it('ignores @ in the middle of lines', () => {
const imports = findImports('Email me at user@example.com');
assert.strictEqual(imports.length, 0);
});
});
describe('extractSections', () => {
it('extracts markdown headings', () => {
const content = '# Title\n## Section 1\nText\n### Sub-section\n## Section 2';
const sections = extractSections(content);
assert.strictEqual(sections.length, 4);
assert.strictEqual(sections[0].heading, 'Title');
assert.strictEqual(sections[0].level, 1);
assert.strictEqual(sections[1].heading, 'Section 1');
assert.strictEqual(sections[1].level, 2);
});
it('returns empty for no headings', () => {
assert.deepStrictEqual(extractSections('Just plain text'), []);
});
it('includes line numbers', () => {
const content = 'line1\n## Heading\nline3';
const sections = extractSections(content);
assert.strictEqual(sections[0].line, 2);
});
});

View file

@ -0,0 +1,110 @@
import { describe, it, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { resetCounter } from '../../scanners/lib/output.mjs';
import { discoverConfigFiles } from '../../scanners/lib/file-discovery.mjs';
import { scan } from '../../scanners/claude-md-linter.mjs';
const __dirname = fileURLToPath(new URL('.', import.meta.url));
const FIXTURES = resolve(__dirname, '../fixtures');
describe('CML scanner — healthy project', () => {
let result;
beforeEach(async () => {
resetCounter();
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'healthy-project'));
result = await scan(resolve(FIXTURES, 'healthy-project'), discovery);
});
it('returns status ok', () => {
assert.strictEqual(result.status, 'ok');
});
it('scans at least 1 file', () => {
assert.ok(result.files_scanned >= 1);
});
it('has scanner prefix CML', () => {
assert.strictEqual(result.scanner, 'CML');
});
it('has all severity count keys', () => {
for (const key of ['critical', 'high', 'medium', 'low', 'info']) {
assert.ok(key in result.counts, `Missing count key: ${key}`);
}
});
it('finds no critical or high issues in healthy project', () => {
const serious = result.findings.filter(f => f.severity === 'critical' || f.severity === 'high');
assert.strictEqual(serious.length, 0, `Found serious issues: ${serious.map(f => f.title).join(', ')}`);
});
it('all finding IDs match CA-CML-NNN pattern', () => {
for (const f of result.findings) {
assert.match(f.id, /^CA-CML-\d{3}$/);
}
});
});
describe('CML scanner — broken project', () => {
let result;
beforeEach(async () => {
resetCounter();
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'broken-project'));
result = await scan(resolve(FIXTURES, 'broken-project'), discovery);
});
it('detects long CLAUDE.md (>200 lines)', () => {
const found = result.findings.some(f => f.title.includes('exceeds'));
assert.ok(found, 'Should detect oversized CLAUDE.md');
});
it('detects missing headings', () => {
const found = result.findings.some(f => f.title.includes('no markdown headings'));
assert.ok(found, 'Should detect lack of headings');
});
it('detects TODO markers', () => {
const found = result.findings.some(f => f.title.includes('TODO'));
assert.ok(found, 'Should detect TODO markers');
});
it('detects repeated content', () => {
const found = result.findings.some(f => f.title.includes('Repeated content'));
assert.ok(found, 'Should detect repeated lines');
});
});
describe('CML scanner — empty project', () => {
let result;
beforeEach(async () => {
resetCounter();
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'empty-project'));
result = await scan(resolve(FIXTURES, 'empty-project'), discovery);
});
it('detects missing CLAUDE.md', () => {
const found = result.findings.some(f => f.title.includes('No CLAUDE.md'));
assert.ok(found, 'Should report missing CLAUDE.md');
});
it('returns high severity for missing CLAUDE.md', () => {
const f = result.findings.find(f => f.title.includes('No CLAUDE.md'));
assert.strictEqual(f?.severity, 'high');
});
});
describe('CML scanner — minimal project', () => {
let result;
beforeEach(async () => {
resetCounter();
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'minimal-project'));
result = await scan(resolve(FIXTURES, 'minimal-project'), discovery);
});
it('detects nearly empty CLAUDE.md', () => {
const found = result.findings.some(f => f.title.includes('nearly empty'));
assert.ok(found, 'Should detect nearly empty CLAUDE.md');
});
});

View file

@ -0,0 +1,124 @@
import { describe, it, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { resetCounter } from '../../scanners/lib/output.mjs';
import { discoverConfigFiles } from '../../scanners/lib/file-discovery.mjs';
import { scan } from '../../scanners/conflict-detector.mjs';
const __dirname = fileURLToPath(new URL('.', import.meta.url));
const FIXTURES = resolve(__dirname, '../fixtures');
describe('CNF scanner — conflict project', () => {
let result;
beforeEach(async () => {
resetCounter();
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'conflict-project'));
result = await scan(resolve(FIXTURES, 'conflict-project'), discovery);
});
it('returns status ok', () => {
assert.equal(result.status, 'ok');
});
it('reports scanner prefix CNF', () => {
assert.equal(result.scanner, 'CNF');
});
it('finding IDs match CA-CNF-NNN pattern', () => {
for (const f of result.findings) {
assert.match(f.id, /^CA-CNF-\d{3}$/);
}
});
it('detects model key conflict', () => {
assert.ok(result.findings.some(f => f.title.includes('model')));
});
it('settings conflict is medium severity', () => {
const model = result.findings.find(f => f.title.includes('model'));
assert.equal(model.severity, 'medium');
});
it('detects effortLevel key conflict', () => {
assert.ok(result.findings.some(f => f.title.includes('effortLevel')));
});
it('detects permission allow/deny conflict', () => {
assert.ok(result.findings.some(f => f.title.includes('Permission allow/deny')));
});
it('permission conflict is high severity', () => {
const perm = result.findings.find(f => f.title.includes('Permission allow/deny'));
assert.equal(perm.severity, 'high');
});
it('detects duplicate hook definition', () => {
assert.ok(result.findings.some(f => f.title.includes('Duplicate hook')));
});
it('duplicate hook is low severity', () => {
const hook = result.findings.find(f => f.title.includes('Duplicate hook'));
assert.equal(hook.severity, 'low');
});
it('has exactly 4 findings', () => {
assert.equal(result.findings.length, 4);
});
it('includes evidence with scope info', () => {
const perm = result.findings.find(f => f.title.includes('Permission'));
assert.ok(perm.evidence);
});
});
describe('CNF scanner — healthy project', () => {
let result;
beforeEach(async () => {
resetCounter();
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'healthy-project'));
result = await scan(resolve(FIXTURES, 'healthy-project'), discovery);
});
it('returns ok with no conflicts', () => {
assert.equal(result.status, 'ok');
});
it('has 0 findings', () => {
assert.equal(result.findings.length, 0);
});
});
describe('CNF scanner — empty project', () => {
let result;
beforeEach(async () => {
resetCounter();
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'empty-project'));
result = await scan(resolve(FIXTURES, 'empty-project'), discovery);
});
it('returns skipped when no config files', () => {
assert.equal(result.status, 'skipped');
});
it('has 0 findings', () => {
assert.equal(result.findings.length, 0);
});
});
describe('CNF scanner — minimal project', () => {
let result;
beforeEach(async () => {
resetCounter();
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'minimal-project'));
result = await scan(resolve(FIXTURES, 'minimal-project'), discovery);
});
it('returns skipped with no settings files', () => {
assert.equal(result.status, 'skipped');
});
it('has 0 findings', () => {
assert.equal(result.findings.length, 0);
});
});

View file

@ -0,0 +1,100 @@
import { describe, it, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { resolve, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { execFileSync } from 'node:child_process';
import { deleteBaseline } from '../../scanners/lib/baseline.mjs';
const __dirname = fileURLToPath(new URL('.', import.meta.url));
const FIXTURES = resolve(__dirname, '../fixtures');
const HEALTHY = resolve(FIXTURES, 'healthy-project');
const DRIFT_CLI = resolve(__dirname, '../../scanners/drift-cli.mjs');
const TEST_BASELINE = `_drift_test_${Date.now()}`;
afterEach(async () => {
await deleteBaseline(TEST_BASELINE);
});
describe('drift-cli --save', () => {
it('saves a baseline and confirms', () => {
const result = execFileSync('node', [DRIFT_CLI, HEALTHY, '--save', '--name', TEST_BASELINE, '--json'], {
encoding: 'utf-8',
timeout: 30000,
});
const output = JSON.parse(result);
assert.equal(output.saved, true);
assert.equal(output.name, TEST_BASELINE);
assert.ok(output.path);
});
});
describe('drift-cli --list', () => {
it('lists baselines including saved one', async () => {
// Save first
execFileSync('node', [DRIFT_CLI, HEALTHY, '--save', '--name', TEST_BASELINE], {
encoding: 'utf-8',
timeout: 30000,
});
const result = execFileSync('node', [DRIFT_CLI, '--list', '--json'], {
encoding: 'utf-8',
timeout: 30000,
});
const output = JSON.parse(result);
assert.ok(Array.isArray(output.baselines));
const found = output.baselines.find(b => b.name === TEST_BASELINE);
assert.ok(found, 'Should find test baseline in list');
});
});
describe('drift-cli compare', () => {
it('outputs valid JSON with --json flag', () => {
// Save baseline first
execFileSync('node', [DRIFT_CLI, HEALTHY, '--save', '--name', TEST_BASELINE], {
encoding: 'utf-8',
timeout: 30000,
});
// Compare same fixture against itself
const result = execFileSync('node', [DRIFT_CLI, HEALTHY, '--baseline', TEST_BASELINE, '--json'], {
encoding: 'utf-8',
timeout: 30000,
});
const diff = JSON.parse(result);
assert.ok('newFindings' in diff);
assert.ok('resolvedFindings' in diff);
assert.ok('unchangedFindings' in diff);
assert.ok('movedFindings' in diff);
assert.ok('scoreChange' in diff);
assert.ok('summary' in diff);
});
it('shows stable trend when comparing same fixture', () => {
execFileSync('node', [DRIFT_CLI, HEALTHY, '--save', '--name', TEST_BASELINE], {
encoding: 'utf-8',
timeout: 30000,
});
const result = execFileSync('node', [DRIFT_CLI, HEALTHY, '--baseline', TEST_BASELINE, '--json'], {
encoding: 'utf-8',
timeout: 30000,
});
const diff = JSON.parse(result);
assert.equal(diff.summary.trend, 'stable');
assert.equal(diff.summary.newCount, 0);
assert.equal(diff.summary.resolvedCount, 0);
});
it('exits with code 1 when baseline not found', () => {
assert.throws(() => {
execFileSync('node', [DRIFT_CLI, HEALTHY, '--baseline', `nonexistent_${Date.now()}`, '--json'], {
encoding: 'utf-8',
timeout: 30000,
});
}, (err) => {
assert.equal(err.status, 1);
return true;
});
});
});

View file

@ -0,0 +1,199 @@
import { describe, it, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { resetCounter } from '../../scanners/lib/output.mjs';
import { scan, opportunitySummary } from '../../scanners/feature-gap-scanner.mjs';
import { discoverConfigFiles } from '../../scanners/lib/file-discovery.mjs';
const __dirname = fileURLToPath(new URL('.', import.meta.url));
const FIXTURES = resolve(__dirname, '../fixtures');
// Pre-discover fixture files WITHOUT includeGlobal so tests are environment-independent.
// The GAP scanner uses shared discovery when it has files, avoiding its own includeGlobal scan.
async function fixtureDiscovery(name) {
return discoverConfigFiles(resolve(FIXTURES, name));
}
describe('GAP scanner — healthy project', () => {
let result;
beforeEach(async () => {
resetCounter();
const discovery = await fixtureDiscovery('healthy-project');
result = await scan(resolve(FIXTURES, 'healthy-project'), discovery);
});
it('returns status ok', () => {
assert.equal(result.status, 'ok');
});
it('reports scanner prefix GAP', () => {
assert.equal(result.scanner, 'GAP');
});
it('scans multiple files', () => {
assert.ok(result.files_scanned >= 1);
});
it('finding IDs match CA-GAP-NNN pattern', () => {
for (const f of result.findings) {
assert.match(f.id, /^CA-GAP-\d{3}$/);
}
});
it('does NOT report missing CLAUDE.md', () => {
assert.ok(!result.findings.some(f => f.title === 'No CLAUDE.md file'));
});
it('does NOT report missing MCP', () => {
assert.ok(!result.findings.some(f => f.title === 'No MCP servers configured'));
});
it('does NOT report missing hooks', () => {
assert.ok(!result.findings.some(f => f.title === 'No hooks configured'));
});
it('has counts object with all severity levels', () => {
assert.ok('critical' in result.counts);
assert.ok('high' in result.counts);
assert.ok('medium' in result.counts);
assert.ok('low' in result.counts);
assert.ok('info' in result.counts);
});
it('has no critical or high findings', () => {
assert.equal(result.counts.critical, 0);
assert.equal(result.counts.high, 0);
});
it('all findings have recommendations', () => {
for (const f of result.findings) {
assert.ok(f.recommendation, `Finding ${f.id} missing recommendation`);
}
});
it('T3/T4 findings are info severity', () => {
const infoFindings = result.findings.filter(f => f.category === 't3' || f.category === 't4');
for (const f of infoFindings) {
assert.equal(f.severity, 'info', `${f.id} (${f.category}) should be info, got ${f.severity}`);
}
});
});
describe('GAP scanner — minimal project', () => {
let result;
beforeEach(async () => {
resetCounter();
const discovery = await fixtureDiscovery('minimal-project');
result = await scan(resolve(FIXTURES, 'minimal-project'), discovery);
});
it('returns status ok', () => {
assert.equal(result.status, 'ok');
});
it('reports missing hooks', () => {
assert.ok(result.findings.some(f => f.title === 'No hooks configured'));
});
it('reports missing MCP', () => {
assert.ok(result.findings.some(f => f.title === 'No MCP servers configured'));
});
it('T1 gaps are medium severity', () => {
const t1 = result.findings.filter(f => f.category === 't1');
for (const f of t1) {
assert.equal(f.severity, 'medium', `${f.id} should be medium, got ${f.severity}`);
}
});
it('T2 gaps are low severity', () => {
const t2 = result.findings.filter(f => f.category === 't2');
for (const f of t2) {
assert.equal(f.severity, 'low', `${f.id} should be low, got ${f.severity}`);
}
});
it('has more findings than healthy project', async () => {
resetCounter();
const discovery = await fixtureDiscovery('healthy-project');
const healthyResult = await scan(resolve(FIXTURES, 'healthy-project'), discovery);
assert.ok(result.findings.length > healthyResult.findings.length);
});
});
describe('GAP scanner — empty project', () => {
let result;
beforeEach(async () => {
resetCounter();
const discovery = await fixtureDiscovery('empty-project');
result = await scan(resolve(FIXTURES, 'empty-project'), discovery);
});
it('returns status ok (never skips)', () => {
assert.equal(result.status, 'ok');
});
it('has multiple medium findings (T1 gaps)', () => {
const mediums = result.findings.filter(f => f.severity === 'medium');
assert.ok(mediums.length >= 1);
});
it('all findings have category field', () => {
for (const f of result.findings) {
assert.ok(f.category, `Finding ${f.id} missing category`);
assert.match(f.category, /^t[1-4]$/);
}
});
it('reports T1 gaps including missing CLAUDE.md', () => {
assert.ok(result.findings.some(f => f.title === 'No CLAUDE.md file'));
});
});
describe('opportunitySummary', () => {
it('returns empty arrays for no findings', () => {
const result = opportunitySummary([]);
assert.deepEqual(result.highImpact, []);
assert.deepEqual(result.mediumImpact, []);
assert.deepEqual(result.explore, []);
});
it('routes T1 to highImpact', () => {
const findings = [{ category: 't1', title: 'No CLAUDE.md' }];
const result = opportunitySummary(findings);
assert.equal(result.highImpact.length, 1);
assert.equal(result.mediumImpact.length, 0);
assert.equal(result.explore.length, 0);
});
it('routes T2 to mediumImpact', () => {
const findings = [{ category: 't2', title: 'Low hook diversity' }];
const result = opportunitySummary(findings);
assert.equal(result.highImpact.length, 0);
assert.equal(result.mediumImpact.length, 1);
});
it('routes T3 and T4 to explore', () => {
const findings = [
{ category: 't3', title: 'No status line' },
{ category: 't4', title: 'No custom plugin' },
];
const result = opportunitySummary(findings);
assert.equal(result.explore.length, 2);
});
it('handles mixed tiers', () => {
const findings = [
{ category: 't1', title: 'A' },
{ category: 't2', title: 'B' },
{ category: 't2', title: 'C' },
{ category: 't3', title: 'D' },
{ category: 't4', title: 'E' },
];
const result = opportunitySummary(findings);
assert.equal(result.highImpact.length, 1);
assert.equal(result.mediumImpact.length, 2);
assert.equal(result.explore.length, 2);
});
});

View file

@ -0,0 +1,91 @@
import { describe, it, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { resolve, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { cp, rm, readFile, stat } from 'node:fs/promises';
import { mkdirSync, existsSync, readdirSync } from 'node:fs';
import { tmpdir, homedir } from 'node:os';
import { execFileSync } from 'node:child_process';
const __dirname = fileURLToPath(new URL('.', import.meta.url));
const FIXTURES = resolve(__dirname, '../fixtures');
const FIXABLE = resolve(FIXTURES, 'fixable-project');
const FIX_CLI = resolve(__dirname, '../../scanners/fix-cli.mjs');
/** Create a temporary copy of the fixable-project fixture. */
async function createTmpCopy() {
const tmpDir = join(tmpdir(), `config-audit-cli-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
mkdirSync(tmpDir, { recursive: true });
await cp(FIXABLE, tmpDir, { recursive: true });
return tmpDir;
}
describe('fix-cli dry-run', () => {
it('shows planned fixes without --apply', () => {
const result = execFileSync('node', [FIX_CLI, FIXABLE, '--json'], {
encoding: 'utf-8',
timeout: 30000,
});
const output = JSON.parse(result);
assert.ok(Array.isArray(output.planned), 'Should have planned array');
assert.ok(output.planned.length > 0, 'Should have planned fixes');
assert.strictEqual(output.backupId, null, 'No backup in dry-run');
assert.ok(Array.isArray(output.manual), 'Should have manual array');
});
it('outputs valid JSON with --json flag', () => {
const result = execFileSync('node', [FIX_CLI, FIXABLE, '--json'], {
encoding: 'utf-8',
timeout: 30000,
});
assert.doesNotThrow(() => JSON.parse(result), 'Output should be valid JSON');
});
});
describe('fix-cli --apply', () => {
let tmpDir;
beforeEach(async () => {
tmpDir = await createTmpCopy();
});
afterEach(async () => {
if (tmpDir) await rm(tmpDir, { recursive: true, force: true });
});
it('applies fixes and creates backup', () => {
const result = execFileSync('node', [FIX_CLI, tmpDir, '--apply', '--json'], {
encoding: 'utf-8',
timeout: 30000,
});
const output = JSON.parse(result);
assert.ok(output.applied.length > 0, 'Should have applied fixes');
assert.ok(output.backupId, 'Should have a backup ID');
// Verify backup exists
const backupDir = join(homedir(), '.config-audit', 'backups', output.backupId);
assert.ok(existsSync(backupDir), 'Backup directory should exist');
});
it('actually modifies files after --apply', async () => {
execFileSync('node', [FIX_CLI, tmpDir, '--apply'], {
encoding: 'utf-8',
timeout: 30000,
});
// Check that settings.json was fixed
const content = await readFile(join(tmpDir, '.claude', 'settings.json'), 'utf-8');
const parsed = JSON.parse(content);
assert.ok(parsed.$schema, 'Should have $schema after fix');
});
it('reports verified fixes', () => {
const result = execFileSync('node', [FIX_CLI, tmpDir, '--apply', '--json'], {
encoding: 'utf-8',
timeout: 30000,
});
const output = JSON.parse(result);
assert.ok(Array.isArray(output.verified), 'Should have verified array');
assert.ok(output.verified.length > 0, 'Should have verified fixes');
});
});

View file

@ -0,0 +1,305 @@
import { describe, it, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { resolve, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { cp, rm, readFile, stat } from 'node:fs/promises';
import { mkdirSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { resetCounter } from '../../scanners/lib/output.mjs';
import { discoverConfigFiles } from '../../scanners/lib/file-discovery.mjs';
import { runAllScanners } from '../../scanners/scan-orchestrator.mjs';
import { planFixes, applyFixes, verifyFixes, FIX_TYPES } from '../../scanners/fix-engine.mjs';
import { parseJson, parseFrontmatter } from '../../scanners/lib/yaml-parser.mjs';
const __dirname = fileURLToPath(new URL('.', import.meta.url));
const FIXTURES = resolve(__dirname, '../fixtures');
const FIXABLE = resolve(FIXTURES, 'fixable-project');
/** Create a temporary copy of the fixable-project fixture. */
async function createTmpCopy() {
const tmpDir = join(tmpdir(), `config-audit-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
mkdirSync(tmpDir, { recursive: true });
await cp(FIXABLE, tmpDir, { recursive: true });
return tmpDir;
}
// --- planFixes tests ---
describe('planFixes', () => {
let envelope;
beforeEach(async () => {
resetCounter();
envelope = await runAllScanners(FIXABLE);
});
it('returns fixes, skipped, and manual arrays', () => {
const result = planFixes(envelope);
assert.ok(Array.isArray(result.fixes));
assert.ok(Array.isArray(result.skipped));
assert.ok(Array.isArray(result.manual));
});
it('identifies auto-fixable findings', () => {
const result = planFixes(envelope);
assert.ok(result.fixes.length > 0, 'Should have at least one fix');
});
it('sorts fixes by severity (critical first)', () => {
const result = planFixes(envelope);
const severityOrder = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
for (let i = 1; i < result.fixes.length; i++) {
const prev = severityOrder[result.fixes[i - 1].severity] || 4;
const curr = severityOrder[result.fixes[i].severity] || 4;
assert.ok(prev <= curr, `Fix ${i} should not have higher severity than fix ${i - 1}`);
}
});
it('includes manual findings with recommendations', () => {
const result = planFixes(envelope);
for (const m of result.manual) {
assert.ok(m.findingId, 'Manual finding should have findingId');
assert.ok(m.title, 'Manual finding should have title');
}
});
it('each fix has required fields', () => {
const result = planFixes(envelope);
for (const fix of result.fixes) {
assert.ok(fix.findingId, 'Fix must have findingId');
assert.ok(fix.file, 'Fix must have file');
assert.ok(fix.type, 'Fix must have type');
assert.ok(fix.description, 'Fix must have description');
}
});
it('detects json-key-add for missing $schema', () => {
const result = planFixes(envelope);
const schemaFix = result.fixes.find(f => f.type === FIX_TYPES.JSON_KEY_ADD && f.key === '$schema');
assert.ok(schemaFix, 'Should have a json-key-add fix for $schema');
});
it('detects json-key-remove for deprecated apiProvider', () => {
const result = planFixes(envelope);
// apiProvider is unknown, not deprecated (includeCoAuthoredBy is deprecated)
// But the fixture has apiProvider which triggers "unknown key" (not auto-fixable)
// The deprecated key in settings-validator is includeCoAuthoredBy — fixture doesn't have it
// Let's check for hooks-as-array instead (critical)
const hooksFix = result.fixes.find(f => f.restructureType === 'hooks-array-to-object');
assert.ok(hooksFix, 'Should have a json-restructure fix for hooks-as-array');
});
it('detects json-key-type-fix for alwaysThinkingEnabled', () => {
const result = planFixes(envelope);
const typeFix = result.fixes.find(f => f.type === FIX_TYPES.JSON_KEY_TYPE_FIX && f.key === 'alwaysThinkingEnabled');
assert.ok(typeFix, 'Should have a type fix for alwaysThinkingEnabled');
});
it('detects json-key-type-fix for effortLevel', () => {
const result = planFixes(envelope);
const effortFix = result.fixes.find(f => f.key === 'effortLevel');
assert.ok(effortFix, 'Should have a fix for invalid effortLevel');
});
it('detects json-restructure for matcher-as-object', () => {
const result = planFixes(envelope);
const matcherFix = result.fixes.find(f => f.restructureType === 'matcher-object-to-string');
assert.ok(matcherFix, 'Should have a restructure fix for matcher-as-object');
});
it('detects json-key-type-fix for timeout-as-string', () => {
const result = planFixes(envelope);
const timeoutFix = result.fixes.find(f => f.key === 'timeout');
assert.ok(timeoutFix, 'Should have a type fix for timeout');
});
it('detects frontmatter-rename for globs→paths', () => {
const result = planFixes(envelope);
const globsFix = result.fixes.find(f => f.type === FIX_TYPES.FRONTMATTER_RENAME);
assert.ok(globsFix, 'Should have a frontmatter-rename fix for globs');
});
it('detects file-rename for non-.md rules file', () => {
const result = planFixes(envelope);
const renameFix = result.fixes.find(f => f.type === FIX_TYPES.FILE_RENAME);
assert.ok(renameFix, 'Should have a file-rename fix');
assert.ok(renameFix.newPath.endsWith('.md'), 'New path should end with .md');
});
});
// --- applyFixes dry-run tests ---
describe('applyFixes dry-run', () => {
let envelope;
beforeEach(async () => {
resetCounter();
envelope = await runAllScanners(FIXABLE);
});
it('returns dry-run status without modifying files', async () => {
const { fixes } = planFixes(envelope);
const result = await applyFixes(fixes, { dryRun: true });
assert.ok(result.applied.length > 0, 'Should have dry-run results');
for (const r of result.applied) {
assert.strictEqual(r.status, 'dry-run');
}
assert.strictEqual(result.failed.length, 0, 'No failures in dry-run');
});
it('throws if no backupDir and not dryRun', async () => {
const { fixes } = planFixes(envelope);
await assert.rejects(
() => applyFixes(fixes, { dryRun: false }),
{ message: /backupDir is required/ },
);
});
});
// --- applyFixes actual (on tmp copies) ---
describe('applyFixes on tmp copy', () => {
let tmpDir;
let envelope;
beforeEach(async () => {
tmpDir = await createTmpCopy();
resetCounter();
envelope = await runAllScanners(tmpDir);
});
afterEach(async () => {
if (tmpDir) await rm(tmpDir, { recursive: true, force: true });
});
it('applies json-key-add ($schema) successfully', async () => {
const { fixes } = planFixes(envelope);
const schemaFix = fixes.filter(f => f.type === FIX_TYPES.JSON_KEY_ADD);
const result = await applyFixes(schemaFix, { dryRun: false, backupDir: tmpDir });
assert.ok(result.applied.length > 0, 'Should apply at least one fix');
assert.strictEqual(result.failed.length, 0, 'No failures');
// Verify file has $schema
const content = await readFile(join(tmpDir, '.claude', 'settings.json'), 'utf-8');
const parsed = parseJson(content);
assert.ok(parsed.$schema, 'Should have $schema key');
assert.ok(parsed.$schema.includes('schemastore'), '$schema should point to schemastore');
});
it('applies json-key-type-fix successfully', async () => {
const { fixes } = planFixes(envelope);
const typeFix = fixes.filter(f => f.type === FIX_TYPES.JSON_KEY_TYPE_FIX && f.key === 'alwaysThinkingEnabled');
const result = await applyFixes(typeFix, { dryRun: false, backupDir: tmpDir });
assert.ok(result.applied.length > 0);
const content = await readFile(join(tmpDir, '.claude', 'settings.json'), 'utf-8');
const parsed = parseJson(content);
assert.strictEqual(typeof parsed.alwaysThinkingEnabled, 'boolean', 'Should be boolean now');
});
it('applies json-restructure (hooks array→object) successfully', async () => {
const { fixes } = planFixes(envelope);
const hooksFix = fixes.filter(f => f.restructureType === 'hooks-array-to-object');
const result = await applyFixes(hooksFix, { dryRun: false, backupDir: tmpDir });
assert.ok(result.applied.length > 0);
const content = await readFile(join(tmpDir, '.claude', 'settings.json'), 'utf-8');
const parsed = parseJson(content);
assert.ok(!Array.isArray(parsed.hooks), 'hooks should be object now');
assert.strictEqual(typeof parsed.hooks, 'object');
});
it('applies json-restructure (matcher object→string) successfully', async () => {
const { fixes } = planFixes(envelope);
const matcherFix = fixes.filter(f => f.restructureType === 'matcher-object-to-string');
const result = await applyFixes(matcherFix, { dryRun: false, backupDir: tmpDir });
assert.ok(result.applied.length > 0);
const content = await readFile(join(tmpDir, 'hooks', 'hooks.json'), 'utf-8');
const parsed = parseJson(content);
const handler = parsed.hooks.PreToolUse[0];
assert.strictEqual(typeof handler.matcher, 'string', 'matcher should be string now');
});
it('applies frontmatter-rename (globs→paths) successfully', async () => {
const { fixes } = planFixes(envelope);
const fmFix = fixes.filter(f => f.type === FIX_TYPES.FRONTMATTER_RENAME);
const result = await applyFixes(fmFix, { dryRun: false, backupDir: tmpDir });
assert.ok(result.applied.length > 0);
const content = await readFile(join(tmpDir, '.claude', 'rules', 'typescript.md'), 'utf-8');
assert.ok(content.includes('paths:'), 'Should have paths: in frontmatter');
assert.ok(!content.includes('globs:'), 'Should not have globs: in frontmatter');
});
it('applies file-rename (non-.md → .md) successfully', async () => {
const { fixes } = planFixes(envelope);
const renameFix = fixes.filter(f => f.type === FIX_TYPES.FILE_RENAME);
const result = await applyFixes(renameFix, { dryRun: false, backupDir: tmpDir });
assert.ok(result.applied.length > 0);
// Old file should be gone
await assert.rejects(() => stat(join(tmpDir, '.claude', 'rules', 'readme.txt')));
// New file should exist
const newStat = await stat(join(tmpDir, '.claude', 'rules', 'readme.md'));
assert.ok(newStat.isFile());
});
it('validates JSON output after fix', async () => {
const { fixes } = planFixes(envelope);
const jsonFixes = fixes.filter(f => f.file.endsWith('.json'));
await applyFixes(jsonFixes, { dryRun: false, backupDir: tmpDir });
// All JSON files should still parse
const settingsContent = await readFile(join(tmpDir, '.claude', 'settings.json'), 'utf-8');
const settingsParsed = parseJson(settingsContent);
assert.ok(settingsParsed !== null, 'settings.json should be valid JSON after fixes');
const hooksContent = await readFile(join(tmpDir, 'hooks', 'hooks.json'), 'utf-8');
const hooksParsed = parseJson(hooksContent);
assert.ok(hooksParsed !== null, 'hooks.json should be valid JSON after fixes');
});
it('fails gracefully for missing file', async () => {
const fakeFix = [{
findingId: 'CA-SET-999',
file: join(tmpDir, 'nonexistent.json'),
type: FIX_TYPES.JSON_KEY_ADD,
severity: 'info',
description: 'Add key to missing file',
key: 'test',
value: true,
}];
const result = await applyFixes(fakeFix, { dryRun: false, backupDir: tmpDir });
assert.strictEqual(result.failed.length, 1, 'Should have one failure');
assert.strictEqual(result.applied.length, 0);
});
});
// --- verifyFixes tests ---
describe('verifyFixes', () => {
let tmpDir;
beforeEach(async () => {
tmpDir = await createTmpCopy();
});
afterEach(async () => {
if (tmpDir) await rm(tmpDir, { recursive: true, force: true });
});
it('confirms fixed findings are gone', async () => {
resetCounter();
const envelope = await runAllScanners(tmpDir);
const { fixes } = planFixes(envelope);
// Apply a subset of fixes
const fmFix = fixes.filter(f => f.type === FIX_TYPES.FRONTMATTER_RENAME);
const result = await applyFixes(fmFix, { dryRun: false, backupDir: tmpDir });
const verification = await verifyFixes(envelope, result.applied);
assert.ok(verification.verified.length > 0, 'Should verify at least one fix');
});
});

View file

@ -0,0 +1,86 @@
import { describe, it, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { resetCounter } from '../../scanners/lib/output.mjs';
import { discoverConfigFiles } from '../../scanners/lib/file-discovery.mjs';
import { scan } from '../../scanners/hook-validator.mjs';
const __dirname = fileURLToPath(new URL('.', import.meta.url));
const FIXTURES = resolve(__dirname, '../fixtures');
describe('HKV scanner — healthy project', () => {
let result;
beforeEach(async () => {
resetCounter();
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'healthy-project'));
result = await scan(resolve(FIXTURES, 'healthy-project'), discovery);
});
it('returns status ok', () => {
assert.strictEqual(result.status, 'ok');
});
it('has scanner prefix HKV', () => {
assert.strictEqual(result.scanner, 'HKV');
});
it('finds no critical or high issues', () => {
const serious = result.findings.filter(f => f.severity === 'critical' || f.severity === 'high');
assert.strictEqual(serious.length, 0, `Found: ${serious.map(f => f.title).join(', ')}`);
});
it('all finding IDs match CA-HKV-NNN', () => {
for (const f of result.findings) {
assert.match(f.id, /^CA-HKV-\d{3}$/);
}
});
});
describe('HKV scanner — broken project', () => {
let result;
beforeEach(async () => {
resetCounter();
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'broken-project'));
result = await scan(resolve(FIXTURES, 'broken-project'), discovery);
});
it('detects unknown hook event', () => {
const found = result.findings.some(f => f.title === 'Unknown hook event');
assert.ok(found, 'Should detect InvalidEvent');
});
it('detects object matcher (should be string)', () => {
const found = result.findings.some(f => f.title.includes('Matcher must be a string'));
assert.ok(found, 'Should detect nested object matcher');
});
it('detects invalid handler type', () => {
const found = result.findings.some(f => f.title === 'Invalid hook handler type');
assert.ok(found, 'Should detect invalid_type');
});
it('detects timeout below minimum', () => {
const found = result.findings.some(f => f.title.includes('timeout'));
assert.ok(found, 'Should detect timeout of 500ms');
});
it('marks unknown event as high severity', () => {
const f = result.findings.find(f => f.title === 'Unknown hook event');
assert.strictEqual(f?.severity, 'high');
});
});
describe('HKV scanner — empty project', () => {
let result;
beforeEach(async () => {
resetCounter();
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'empty-project'));
result = await scan(resolve(FIXTURES, 'empty-project'), discovery);
});
it('returns status ok with 0 findings', () => {
assert.strictEqual(result.status, 'ok');
assert.strictEqual(result.findings.length, 0);
});
});

View file

@ -0,0 +1,117 @@
import { describe, it, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { resetCounter } from '../../scanners/lib/output.mjs';
import { discoverConfigFiles } from '../../scanners/lib/file-discovery.mjs';
import { scan } from '../../scanners/import-resolver.mjs';
const __dirname = fileURLToPath(new URL('.', import.meta.url));
const FIXTURES = resolve(__dirname, '../fixtures');
describe('IMP scanner — healthy project', () => {
let result;
beforeEach(async () => {
resetCounter();
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'healthy-project'));
result = await scan(resolve(FIXTURES, 'healthy-project'), discovery);
});
it('returns status ok', () => {
assert.equal(result.status, 'ok');
});
it('reports scanner prefix IMP', () => {
assert.equal(result.scanner, 'IMP');
});
it('scans at least 1 file', () => {
assert.ok(result.files_scanned >= 1);
});
it('has no high or critical findings', () => {
assert.equal(result.counts.critical, 0);
assert.equal(result.counts.high, 0);
});
it('finding IDs match CA-IMP-NNN pattern', () => {
for (const f of result.findings) {
assert.match(f.id, /^CA-IMP-\d{3}$/);
}
});
});
describe('IMP scanner — broken project', () => {
let result;
beforeEach(async () => {
resetCounter();
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'broken-project'));
result = await scan(resolve(FIXTURES, 'broken-project'), discovery);
});
it('returns status ok', () => {
assert.equal(result.status, 'ok');
});
it('detects broken @import link', () => {
assert.ok(result.findings.some(f => f.title.includes('Broken @import')));
});
it('broken link is high severity', () => {
const broken = result.findings.find(f => f.title.includes('Broken @import'));
assert.equal(broken.severity, 'high');
});
it('detects circular @import reference', () => {
assert.ok(result.findings.some(f => f.title.includes('Circular @import')));
});
it('circular reference is medium severity', () => {
const circular = result.findings.find(f => f.title.includes('Circular @import'));
assert.equal(circular.severity, 'medium');
});
it('has at least 2 findings', () => {
assert.ok(result.findings.length >= 2);
});
it('includes evidence with path info', () => {
const broken = result.findings.find(f => f.title.includes('Broken @import'));
assert.ok(broken.evidence);
assert.ok(broken.evidence.includes('nonexistent'));
});
});
describe('IMP scanner — minimal project', () => {
let result;
beforeEach(async () => {
resetCounter();
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'minimal-project'));
result = await scan(resolve(FIXTURES, 'minimal-project'), discovery);
});
it('returns status ok', () => {
assert.equal(result.status, 'ok');
});
it('has 0 findings for file without imports', () => {
assert.equal(result.findings.length, 0);
});
});
describe('IMP scanner — empty project', () => {
let result;
beforeEach(async () => {
resetCounter();
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'empty-project'));
result = await scan(resolve(FIXTURES, 'empty-project'), discovery);
});
it('returns skipped when no CLAUDE.md files', () => {
assert.equal(result.status, 'skipped');
});
it('has 0 findings', () => {
assert.equal(result.findings.length, 0);
});
});

View file

@ -0,0 +1,136 @@
import { describe, it, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { resetCounter } from '../../scanners/lib/output.mjs';
import { discoverConfigFiles } from '../../scanners/lib/file-discovery.mjs';
import { scan } from '../../scanners/mcp-config-validator.mjs';
const __dirname = fileURLToPath(new URL('.', import.meta.url));
const FIXTURES = resolve(__dirname, '../fixtures');
describe('MCP scanner — healthy project', () => {
let result;
beforeEach(async () => {
resetCounter();
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'healthy-project'));
result = await scan(resolve(FIXTURES, 'healthy-project'), discovery);
});
it('returns status ok', () => {
assert.equal(result.status, 'ok');
});
it('reports scanner prefix MCP', () => {
assert.equal(result.scanner, 'MCP');
});
it('scans at least 1 file', () => {
assert.ok(result.files_scanned >= 1);
});
it('has no critical or high findings', () => {
assert.equal(result.counts.critical, 0);
assert.equal(result.counts.high, 0);
});
it('finding IDs match CA-MCP-NNN pattern', () => {
for (const f of result.findings) {
assert.match(f.id, /^CA-MCP-\d{3}$/);
}
});
it('has counts object with all severity levels', () => {
assert.ok('critical' in result.counts);
assert.ok('high' in result.counts);
assert.ok('medium' in result.counts);
assert.ok('low' in result.counts);
assert.ok('info' in result.counts);
});
});
describe('MCP scanner — broken project', () => {
let result;
beforeEach(async () => {
resetCounter();
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'broken-project'));
result = await scan(resolve(FIXTURES, 'broken-project'), discovery);
});
it('returns status ok', () => {
assert.equal(result.status, 'ok');
});
it('detects SSE server type', () => {
assert.ok(result.findings.some(f => f.title.includes('SSE')));
});
it('SSE recommendation is info severity', () => {
const sse = result.findings.find(f => f.title.includes('SSE'));
assert.equal(sse.severity, 'info');
});
it('detects unknown server type', () => {
assert.ok(result.findings.some(f => f.title.includes('Unknown MCP server type')));
});
it('unknown server type is high severity', () => {
const unknown = result.findings.find(f => f.title.includes('Unknown MCP server type'));
assert.equal(unknown.severity, 'high');
});
it('detects missing trust level', () => {
assert.ok(result.findings.some(f => f.title.includes('Missing trust level')));
});
it('missing trust is medium severity', () => {
const trust = result.findings.find(f => f.title.includes('Missing trust level'));
assert.equal(trust.severity, 'medium');
});
it('detects unreferenced env vars in args', () => {
assert.ok(result.findings.some(f => f.title.includes('Unreferenced env var')));
});
it('detects unknown server fields', () => {
assert.ok(result.findings.some(f => f.title.includes('Unknown MCP server field')));
});
it('has multiple findings', () => {
assert.ok(result.findings.length >= 5);
});
});
describe('MCP scanner — empty project', () => {
let result;
beforeEach(async () => {
resetCounter();
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'empty-project'));
result = await scan(resolve(FIXTURES, 'empty-project'), discovery);
});
it('returns skipped status', () => {
assert.equal(result.status, 'skipped');
});
it('has 0 findings', () => {
assert.equal(result.findings.length, 0);
});
});
describe('MCP scanner — minimal project', () => {
let result;
beforeEach(async () => {
resetCounter();
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'minimal-project'));
result = await scan(resolve(FIXTURES, 'minimal-project'), discovery);
});
it('returns skipped when no .mcp.json', () => {
assert.equal(result.status, 'skipped');
});
it('has 0 findings', () => {
assert.equal(result.findings.length, 0);
});
});

View file

@ -0,0 +1,128 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { resolve, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { resetCounter } from '../../scanners/lib/output.mjs';
import { scan, discoverPlugins } from '../../scanners/plugin-health-scanner.mjs';
const __dirname = fileURLToPath(new URL('.', import.meta.url));
const FIXTURES = resolve(__dirname, '../fixtures');
const TEST_PLUGIN = resolve(FIXTURES, 'test-plugin');
const BROKEN_PLUGIN = resolve(FIXTURES, 'broken-plugin');
describe('discoverPlugins', () => {
it('discovers a single plugin when pointed at plugin dir', async () => {
const plugins = await discoverPlugins(TEST_PLUGIN);
assert.equal(plugins.length, 1);
assert.ok(plugins[0].endsWith('test-plugin'));
});
it('discovers multiple plugins in parent dir', async () => {
const plugins = await discoverPlugins(FIXTURES);
// Should find test-plugin and broken-plugin (both have .claude-plugin/plugin.json)
assert.ok(plugins.length >= 2, `Expected >=2, got ${plugins.length}`);
});
it('returns empty array for dir with no plugins', async () => {
const plugins = await discoverPlugins(resolve(FIXTURES, 'empty-project'));
assert.equal(plugins.length, 0);
});
});
describe('scan on valid test-plugin', () => {
it('returns ok status', async () => {
resetCounter();
const result = await scan(TEST_PLUGIN);
assert.equal(result.scanner, 'PLH');
assert.equal(result.status, 'ok');
});
it('finds commands and agents', async () => {
resetCounter();
const result = await scan(TEST_PLUGIN);
assert.ok(result.files_scanned >= 1, 'Should scan at least 1 plugin');
// Valid plugin should have few or no findings
const criticals = result.findings.filter(f => f.severity === 'critical');
assert.equal(criticals.length, 0, 'Valid plugin should have no critical findings');
});
it('no findings for missing plugin.json fields', async () => {
resetCounter();
const result = await scan(TEST_PLUGIN);
const missingFields = result.findings.filter(f => f.title.includes('Missing required field'));
assert.equal(missingFields.length, 0, 'All required fields present in test-plugin');
});
it('no findings for missing CLAUDE.md sections', async () => {
resetCounter();
const result = await scan(TEST_PLUGIN);
const missingSections = result.findings.filter(f => f.title.includes('missing') && f.title.includes('section'));
assert.equal(missingSections.length, 0, 'All sections present in test-plugin CLAUDE.md');
});
});
describe('scan on broken-plugin', () => {
it('detects missing plugin.json fields', async () => {
resetCounter();
const result = await scan(BROKEN_PLUGIN);
const missingFields = result.findings.filter(f => f.title.includes('Missing required field'));
assert.ok(missingFields.length >= 2, 'Should detect missing description and version');
});
it('detects missing CLAUDE.md', async () => {
resetCounter();
const result = await scan(BROKEN_PLUGIN);
const missingMd = result.findings.filter(f => f.title === 'Missing CLAUDE.md');
assert.equal(missingMd.length, 1, 'Should detect missing CLAUDE.md');
});
it('detects command without frontmatter', async () => {
resetCounter();
const result = await scan(BROKEN_PLUGIN);
const noFrontmatter = result.findings.filter(f => f.title === 'Command missing frontmatter');
assert.equal(noFrontmatter.length, 1, 'Should detect command without frontmatter');
});
it('detects agent missing required frontmatter fields', async () => {
resetCounter();
const result = await scan(BROKEN_PLUGIN);
const missingAgent = result.findings.filter(f =>
f.title.startsWith('Agent missing frontmatter field:')
);
// bad-agent.md has name+description but missing model and tools
assert.ok(missingAgent.length >= 2, `Should detect missing model and tools, got ${missingAgent.length}: ${missingAgent.map(f => f.title).join(', ')}`);
});
});
describe('scan with no plugins', () => {
it('returns info finding for empty directory', async () => {
resetCounter();
const result = await scan(resolve(FIXTURES, 'empty-project'));
assert.equal(result.findings.length, 1);
assert.equal(result.findings[0].title, 'No plugins found');
assert.equal(result.findings[0].severity, 'info');
});
});
describe('cross-plugin command conflict detection', () => {
it('scans fixtures dir and reports findings for all plugins', async () => {
resetCounter();
const result = await scan(FIXTURES);
assert.equal(result.scanner, 'PLH');
assert.ok(result.files_scanned >= 2, 'Should scan multiple plugins');
});
});
describe('finding format', () => {
it('findings have standard fields', async () => {
resetCounter();
const result = await scan(BROKEN_PLUGIN);
assert.ok(result.findings.length > 0);
const f = result.findings[0];
assert.ok(f.id.startsWith('CA-PLH-'));
assert.equal(f.scanner, 'PLH');
assert.ok(['critical', 'high', 'medium', 'low', 'info'].includes(f.severity));
assert.ok(f.title);
assert.ok(f.description);
});
});

View file

@ -0,0 +1,123 @@
import { describe, it, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
const exec = promisify(execFile);
const __dirname = fileURLToPath(new URL('.', import.meta.url));
const FIXTURES = resolve(__dirname, '../fixtures');
const POSTURE_BIN = resolve(__dirname, '../../scanners/posture.mjs');
async function runPosture(args) {
const { stdout, stderr } = await exec('node', [POSTURE_BIN, ...args], {
timeout: 30000,
cwd: resolve(__dirname, '../..'),
});
return { stdout, stderr };
}
async function runPostureJson(fixturePath) {
const { stdout } = await runPosture([fixturePath, '--json']);
return JSON.parse(stdout);
}
describe('posture.mjs CLI — healthy project', () => {
let result;
beforeEach(async () => {
result = await runPostureJson(resolve(FIXTURES, 'healthy-project'));
});
it('returns utilization with score and overhang', () => {
assert.ok(typeof result.utilization.score === 'number');
assert.ok(typeof result.utilization.overhang === 'number');
assert.equal(result.utilization.score + result.utilization.overhang, 100);
});
it('returns maturity level >= 2', () => {
assert.ok(result.maturity.level >= 2);
assert.ok(typeof result.maturity.name === 'string');
});
it('returns segment string', () => {
assert.ok(typeof result.segment.segment === 'string');
assert.ok(result.segment.segment.length > 0);
});
it('returns 8 area scores', () => {
assert.equal(result.areas.length, 8);
for (const area of result.areas) {
assert.ok('name' in area);
assert.ok('grade' in area);
assert.ok('score' in area);
assert.ok('findingCount' in area);
}
});
it('returns overallGrade', () => {
assert.ok(['A', 'B', 'C', 'D', 'F'].includes(result.overallGrade));
});
it('includes topActions array', () => {
assert.ok(Array.isArray(result.topActions));
});
it('includes scannerEnvelope', () => {
assert.ok(result.scannerEnvelope.meta);
assert.ok(result.scannerEnvelope.scanners);
assert.ok(result.scannerEnvelope.aggregate);
});
});
describe('posture.mjs CLI — minimal project', () => {
it('scores lower utilization than healthy', async () => {
const healthy = await runPostureJson(resolve(FIXTURES, 'healthy-project'));
const minimal = await runPostureJson(resolve(FIXTURES, 'minimal-project'));
assert.ok(minimal.utilization.score < healthy.utilization.score,
`minimal (${minimal.utilization.score}) should be < healthy (${healthy.utilization.score})`);
});
it('has lower maturity than healthy', async () => {
const healthy = await runPostureJson(resolve(FIXTURES, 'healthy-project'));
const minimal = await runPostureJson(resolve(FIXTURES, 'minimal-project'));
assert.ok(minimal.maturity.level <= healthy.maturity.level);
});
});
describe('posture.mjs CLI — terminal output (v3 health format)', () => {
it('scorecard contains health sections', async () => {
const { stderr } = await runPosture([resolve(FIXTURES, 'healthy-project')]);
assert.ok(stderr.includes('Config-Audit Health Score'));
assert.ok(stderr.includes('Health:'));
assert.ok(stderr.includes('Area Scores'));
assert.ok(stderr.includes('areas scanned'));
});
it('scorecard does NOT contain legacy metrics', async () => {
const { stderr } = await runPosture([resolve(FIXTURES, 'healthy-project')]);
assert.ok(!stderr.includes('Maturity:'));
assert.ok(!stderr.includes('Utilization:'));
assert.ok(!stderr.includes('Segment:'));
});
it('scorecard excludes Feature Coverage from area display', async () => {
const { stderr } = await runPosture([resolve(FIXTURES, 'healthy-project')]);
assert.ok(!stderr.includes('Feature Coverage'));
});
});
describe('posture.mjs CLI — JSON includes opportunityCount', () => {
it('returns opportunityCount field', async () => {
const result = await runPostureJson(resolve(FIXTURES, 'healthy-project'));
assert.ok(typeof result.opportunityCount === 'number');
assert.ok(result.opportunityCount >= 0);
});
it('JSON still includes legacy fields for backward compat', async () => {
const result = await runPostureJson(resolve(FIXTURES, 'healthy-project'));
assert.ok(typeof result.utilization.score === 'number');
assert.ok(typeof result.maturity.level === 'number');
assert.ok(typeof result.segment.segment === 'string');
});
});

View file

@ -0,0 +1,128 @@
import { describe, it, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { join } from 'node:path';
import { writeFile, readFile, mkdir, rm, stat } from 'node:fs/promises';
import { mkdirSync, writeFileSync } from 'node:fs';
import { tmpdir, homedir } from 'node:os';
import { createBackup, getBackupDir, checksum } from '../../scanners/lib/backup.mjs';
import { listBackups, restoreBackup, deleteBackup } from '../../scanners/rollback-engine.mjs';
/** Create a temp file and back it up, returning paths and content. */
async function setupTestBackup() {
const tmpDir = join(tmpdir(), `config-audit-rb-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
mkdirSync(tmpDir, { recursive: true });
const testFile = join(tmpDir, 'test-settings.json');
const originalContent = '{"original": true, "key": "value"}';
writeFileSync(testFile, originalContent);
const backup = createBackup([testFile]);
// Now modify the file to simulate a change
writeFileSync(testFile, '{"modified": true}');
return { tmpDir, testFile, originalContent, backup };
}
describe('listBackups', () => {
it('returns an array of backups', async () => {
const result = await listBackups();
assert.ok(Array.isArray(result.backups), 'Should return backups array');
});
it('backups are sorted newest first', async () => {
const result = await listBackups();
if (result.backups.length >= 2) {
assert.ok(result.backups[0].id >= result.backups[1].id, 'First backup should be newer');
}
});
it('each backup has required fields', async () => {
const { tmpDir, backup } = await setupTestBackup();
try {
const result = await listBackups();
const found = result.backups.find(b => b.id === backup.backupId);
assert.ok(found, 'Should find our test backup');
assert.ok(found.id, 'Backup should have id');
assert.ok(found.createdAt, 'Backup should have createdAt');
assert.ok(Array.isArray(found.files), 'Backup should have files array');
assert.ok(found.files.length > 0, 'Backup should have at least one file');
assert.ok(found.files[0].originalPath, 'File entry should have originalPath');
assert.ok(found.files[0].checksum, 'File entry should have checksum');
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
});
});
describe('restoreBackup', () => {
let tmpDir, testFile, originalContent, backup;
beforeEach(async () => {
({ tmpDir, testFile, originalContent, backup } = await setupTestBackup());
});
afterEach(async () => {
if (tmpDir) await rm(tmpDir, { recursive: true, force: true });
// Cleanup our test backup
try { await deleteBackup(backup.backupId); } catch {}
});
it('restores files to original content', async () => {
const result = await restoreBackup(backup.backupId);
assert.ok(result.restored.length > 0, 'Should restore at least one file');
assert.strictEqual(result.failed.length, 0, 'No failures');
const restoredContent = await readFile(testFile, 'utf-8');
assert.strictEqual(restoredContent, originalContent, 'Content should match original');
});
it('verifies checksums after restore', async () => {
const result = await restoreBackup(backup.backupId, { verify: true });
for (const r of result.restored) {
assert.strictEqual(r.status, 'restored');
}
});
it('dry-run returns plan without writing', async () => {
const result = await restoreBackup(backup.backupId, { dryRun: true });
assert.ok(result.restored.length > 0);
for (const r of result.restored) {
assert.strictEqual(r.status, 'dry-run');
}
// File should still be modified
const content = await readFile(testFile, 'utf-8');
assert.strictEqual(content, '{"modified": true}', 'File should not be restored in dry-run');
});
it('throws for invalid backup-id', async () => {
await assert.rejects(
() => restoreBackup('nonexistent_99999999_999999'),
{ message: /Backup not found/ },
);
});
});
describe('deleteBackup', () => {
it('deletes an existing backup', async () => {
const { tmpDir, backup } = await setupTestBackup();
try {
const result = await deleteBackup(backup.backupId);
assert.strictEqual(result.deleted, true);
// Verify it's gone from the list
const list = await listBackups();
const found = list.backups.find(b => b.id === backup.backupId);
assert.ok(!found, 'Deleted backup should not appear in list');
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
});
it('returns error for nonexistent backup', async () => {
const result = await deleteBackup('nonexistent_99999999_999999');
assert.strictEqual(result.deleted, false);
assert.ok(result.error, 'Should have error message');
});
});

View file

@ -0,0 +1,84 @@
import { describe, it, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { resetCounter } from '../../scanners/lib/output.mjs';
import { discoverConfigFiles } from '../../scanners/lib/file-discovery.mjs';
import { scan } from '../../scanners/rules-validator.mjs';
const __dirname = fileURLToPath(new URL('.', import.meta.url));
const FIXTURES = resolve(__dirname, '../fixtures');
describe('RUL scanner — healthy project', () => {
let result;
beforeEach(async () => {
resetCounter();
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'healthy-project'));
result = await scan(resolve(FIXTURES, 'healthy-project'), discovery);
});
it('returns status ok', () => {
assert.strictEqual(result.status, 'ok');
});
it('has scanner prefix RUL', () => {
assert.strictEqual(result.scanner, 'RUL');
});
it('finds no high severity issues', () => {
const high = result.findings.filter(f => f.severity === 'high' || f.severity === 'critical');
assert.strictEqual(high.length, 0, `Found: ${high.map(f => f.title + ': ' + f.description).join('\n')}`);
});
it('all finding IDs match CA-RUL-NNN', () => {
for (const f of result.findings) {
assert.match(f.id, /^CA-RUL-\d{3}$/);
}
});
});
describe('RUL scanner — broken project', () => {
let result;
beforeEach(async () => {
resetCounter();
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'broken-project'));
result = await scan(resolve(FIXTURES, 'broken-project'), discovery);
});
it('detects deprecated globs field', () => {
const found = result.findings.some(f => f.title.includes('deprecated'));
assert.ok(found, 'Should detect globs: instead of paths:');
});
it('detects dead rule (matches no files)', () => {
const found = result.findings.some(f => f.title.includes('matches no files'));
assert.ok(found, 'Should detect dead glob pattern');
});
it('detects large unscoped rule', () => {
const found = result.findings.some(f => f.title.includes('unscoped'));
assert.ok(found, 'Should detect big rule without paths: frontmatter');
});
it('marks dead rule as high severity', () => {
const f = result.findings.find(f => f.title.includes('matches no files'));
assert.strictEqual(f?.severity, 'high');
});
});
describe('RUL scanner — empty project', () => {
let result;
beforeEach(async () => {
resetCounter();
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'empty-project'));
result = await scan(resolve(FIXTURES, 'empty-project'), discovery);
});
it('returns skipped when no rule files', () => {
assert.strictEqual(result.status, 'skipped');
});
it('has 0 findings', () => {
assert.strictEqual(result.findings.length, 0);
});
});

View file

@ -0,0 +1,172 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { resolve, dirname, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import { isFixturePath, FIXTURE_DIR_NAMES, runAllScanners } from '../../scanners/scan-orchestrator.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const PLUGIN_ROOT = resolve(__dirname, '../..');
const FIXTURES = resolve(__dirname, '../fixtures');
// ========================================
// isFixturePath
// ========================================
describe('isFixturePath', () => {
const target = '/repo';
it('matches tests/ subdirectory relative to target', () => {
assert.strictEqual(isFixturePath({ file: '/repo/tests/fixtures/CLAUDE.md' }, target), true);
});
it('matches examples/ subdirectory relative to target', () => {
assert.strictEqual(isFixturePath({ file: '/repo/examples/demo/settings.json' }, target), true);
});
it('matches __tests__/ subdirectory', () => {
assert.strictEqual(isFixturePath({ file: '/repo/__tests__/config/CLAUDE.md' }, target), true);
});
it('does not match production paths', () => {
assert.strictEqual(isFixturePath({ file: '/repo/CLAUDE.md' }, target), false);
assert.strictEqual(isFixturePath({ file: '/repo/plugins/config-audit/CLAUDE.md' }, target), false);
});
it('does not filter when target IS a fixture directory', () => {
// If we're scanning tests/fixtures/broken-project directly, its files should NOT be filtered
const fixtureTarget = '/repo/tests/fixtures/broken-project';
assert.strictEqual(
isFixturePath({ file: '/repo/tests/fixtures/broken-project/CLAUDE.md' }, fixtureTarget),
false,
'Files at the root of the scanned target should not be filtered'
);
});
it('falls back to path field', () => {
assert.strictEqual(isFixturePath({ path: '/repo/tests/broken/hooks.json' }, target), true);
});
it('falls back to location field', () => {
assert.strictEqual(isFixturePath({ location: '/repo/examples/bad.md' }, target), true);
});
it('returns false when file is null (GAP findings)', () => {
assert.strictEqual(isFixturePath({ file: null }, target), false);
});
it('returns false for empty finding (no file/path/location)', () => {
assert.strictEqual(isFixturePath({}, target), false);
});
it('returns false when file is outside target path', () => {
assert.strictEqual(isFixturePath({ file: '/other/tests/foo.md' }, target), false);
});
it('uses platform-native separator (path.sep)', () => {
// On macOS/Linux sep='/', on Windows sep='\\'
// This test verifies the function works with the native separator
const nativePath = `${target}${sep}tests${sep}fixtures${sep}CLAUDE.md`;
assert.strictEqual(isFixturePath({ file: nativePath }, target), true);
});
});
// ========================================
// FIXTURE_DIR_NAMES
// ========================================
describe('FIXTURE_DIR_NAMES', () => {
it('contains expected directory names', () => {
assert.ok(FIXTURE_DIR_NAMES.includes('tests'));
assert.ok(FIXTURE_DIR_NAMES.includes('examples'));
assert.ok(FIXTURE_DIR_NAMES.includes('__tests__'));
});
});
// ========================================
// runAllScanners — fixture filtering
// ========================================
describe('runAllScanners — fixture filtering', () => {
it('excludes fixture findings by default when scanning plugin root', async () => {
const env = await runAllScanners(PLUGIN_ROOT);
// The plugin has test fixtures in tests/fixtures/ — those should be filtered out
const allFindingFiles = env.scanners.flatMap(s => s.findings.map(f => f.file)).filter(Boolean);
const fixtureInResults = allFindingFiles.filter(f => f.includes('/tests/'));
assert.strictEqual(fixtureInResults.length, 0, 'No fixture findings should appear in scanner results');
});
it('stores excluded findings in env.fixture_findings', async () => {
const env = await runAllScanners(PLUGIN_ROOT);
// Plugin has intentionally broken fixtures — at least some findings should be excluded
if (env.fixture_findings) {
assert.ok(Array.isArray(env.fixture_findings));
assert.ok(env.fixture_findings.length > 0, 'Expected fixture findings to be captured');
// All fixture findings should have test/example paths
for (const f of env.fixture_findings) {
const p = f.file || f.path || f.location || '';
assert.ok(
p.includes('/tests/') || p.includes('/examples/'),
`Fixture finding path should contain /tests/ or /examples/: ${p}`
);
}
}
// Note: if no fixture findings exist (unlikely but possible), test still passes
});
it('includes fixture findings when filterFixtures is false', async () => {
const env = await runAllScanners(PLUGIN_ROOT, { filterFixtures: false });
assert.strictEqual(env.fixture_findings, undefined, 'No fixture_findings field when filtering disabled');
// Some findings should have test fixture paths
const allFindingFiles = env.scanners.flatMap(s => s.findings.map(f => f.file)).filter(Boolean);
const fixtureInResults = allFindingFiles.filter(f => f.includes('/tests/'));
assert.ok(fixtureInResults.length > 0, 'Fixture findings should be present when filtering disabled');
});
it('does not filter GAP findings (file is null)', async () => {
const env = await runAllScanners(PLUGIN_ROOT);
const gapScanner = env.scanners.find(s => s.scanner === 'GAP');
assert.ok(gapScanner, 'GAP scanner should be present');
// GAP findings have file: null — they should never be filtered
for (const f of gapScanner.findings) {
assert.strictEqual(f.file, null, 'GAP findings have null file and should not be filtered');
}
});
it('recalculates scanner counts after fixture filtering', async () => {
const env = await runAllScanners(PLUGIN_ROOT);
for (const scanner of env.scanners) {
// Verify counts match actual findings
const expected = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
for (const f of scanner.findings) {
if (expected[f.severity] !== undefined) expected[f.severity]++;
}
assert.deepStrictEqual(scanner.counts, expected,
`Scanner ${scanner.scanner}: counts should match actual findings after filtering`);
}
});
it('total_findings in aggregate excludes fixtures', async () => {
const withFilter = await runAllScanners(PLUGIN_ROOT, { filterFixtures: true });
const withoutFilter = await runAllScanners(PLUGIN_ROOT, { filterFixtures: false });
// With filter should have fewer or equal findings
assert.ok(
withFilter.aggregate.total_findings <= withoutFilter.aggregate.total_findings,
`Filtered total (${withFilter.aggregate.total_findings}) should be <= unfiltered (${withoutFilter.aggregate.total_findings})`
);
});
it('fixture filtering and suppression are independent', async () => {
// Both enabled (default)
const both = await runAllScanners(PLUGIN_ROOT, { filterFixtures: true, suppress: true });
// Only fixtures
const fixturesOnly = await runAllScanners(PLUGIN_ROOT, { filterFixtures: true, suppress: false });
// Only suppression
const suppressOnly = await runAllScanners(PLUGIN_ROOT, { filterFixtures: false, suppress: true });
// fixture_findings should be present in both fixture-filtered runs
if (both.fixture_findings) {
assert.ok(fixturesOnly.fixture_findings, 'fixture_findings should be present regardless of suppress flag');
}
// suppressed_findings should be present in both suppression-enabled runs (if any suppressions exist)
if (both.suppressed_findings) {
assert.ok(suppressOnly.suppressed_findings, 'suppressed_findings should be present regardless of filterFixtures flag');
}
});
});

View file

@ -0,0 +1,90 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { runSelfAudit, formatSelfAudit } from '../../scanners/self-audit.mjs';
// ========================================
// runSelfAudit
// ========================================
describe('runSelfAudit', () => {
it('runs without crash', async () => {
const result = await runSelfAudit();
assert.ok(result);
assert.ok(typeof result.configGrade === 'string');
assert.ok(typeof result.pluginGrade === 'string');
});
it('returns combined results (scanners + plugin health)', async () => {
const result = await runSelfAudit();
assert.ok(result.configEnvelope);
assert.ok(result.pluginHealthResult);
assert.ok(Array.isArray(result.allFindings));
});
it('has valid exit code', async () => {
const result = await runSelfAudit();
assert.ok([0, 1, 2].includes(result.exitCode));
});
it('includes verdict', async () => {
const result = await runSelfAudit();
assert.ok(['PASS', 'WARN', 'FAIL'].includes(result.verdict));
});
it('has numeric scores', async () => {
const result = await runSelfAudit();
assert.ok(typeof result.configScore === 'number');
assert.ok(typeof result.pluginScore === 'number');
assert.ok(result.configScore >= 0 && result.configScore <= 100);
assert.ok(result.pluginScore >= 0 && result.pluginScore <= 100);
});
it('points to correct plugin directory', async () => {
const result = await runSelfAudit();
assert.ok(result.pluginDir.includes('config-audit'));
});
});
// ========================================
// fixture filtering delegation
// ========================================
describe('runSelfAudit — fixture filtering', () => {
it('does not include fixture findings in allFindings', async () => {
const result = await runSelfAudit();
for (const f of result.allFindings) {
const p = f.file || f.path || f.location || '';
assert.ok(
!p.includes('/tests/fixtures/'),
`allFindings should not contain fixture paths: ${p}`
);
}
});
it('configEnvelope has fixture_findings from orchestrator', async () => {
const result = await runSelfAudit();
// The orchestrator filters fixtures and attaches them to the envelope
if (result.configEnvelope.fixture_findings) {
assert.ok(Array.isArray(result.configEnvelope.fixture_findings));
assert.ok(result.configEnvelope.fixture_findings.length > 0);
}
});
});
// ========================================
// formatSelfAudit
// ========================================
describe('formatSelfAudit', () => {
it('produces terminal output with Self-Audit header', async () => {
const result = await runSelfAudit();
const output = formatSelfAudit(result);
assert.ok(output.includes('Self-Audit'));
assert.ok(output.includes('Plugin health:'));
assert.ok(output.includes('Config quality:'));
});
it('includes verdict', async () => {
const result = await runSelfAudit();
const output = formatSelfAudit(result);
assert.ok(output.includes('Self-audit:'));
assert.ok(output.includes('PASS') || output.includes('WARN') || output.includes('FAIL'));
});
});

View file

@ -0,0 +1,94 @@
import { describe, it, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { resetCounter } from '../../scanners/lib/output.mjs';
import { discoverConfigFiles } from '../../scanners/lib/file-discovery.mjs';
import { scan } from '../../scanners/settings-validator.mjs';
const __dirname = fileURLToPath(new URL('.', import.meta.url));
const FIXTURES = resolve(__dirname, '../fixtures');
describe('SET scanner — healthy project', () => {
let result;
beforeEach(async () => {
resetCounter();
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'healthy-project'));
result = await scan(resolve(FIXTURES, 'healthy-project'), discovery);
});
it('returns status ok', () => {
assert.strictEqual(result.status, 'ok');
});
it('has scanner prefix SET', () => {
assert.strictEqual(result.scanner, 'SET');
});
it('finds no critical issues', () => {
const critical = result.findings.filter(f => f.severity === 'critical');
assert.strictEqual(critical.length, 0);
});
it('all finding IDs match CA-SET-NNN', () => {
for (const f of result.findings) {
assert.match(f.id, /^CA-SET-\d{3}$/);
}
});
});
describe('SET scanner — broken project', () => {
let result;
beforeEach(async () => {
resetCounter();
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'broken-project'));
result = await scan(resolve(FIXTURES, 'broken-project'), discovery);
});
it('detects unknown settings key', () => {
const found = result.findings.some(f => f.title === 'Unknown settings key');
assert.ok(found, 'Should detect unknownKey123');
});
it('detects deprecated key (includeCoAuthoredBy)', () => {
const found = result.findings.some(f => f.title === 'Deprecated settings key');
assert.ok(found, 'Should detect includeCoAuthoredBy');
});
it('detects type mismatch (alwaysThinkingEnabled as string)', () => {
const found = result.findings.some(f => f.title === 'Type mismatch in settings');
assert.ok(found, 'Should detect boolean/string mismatch');
});
it('detects invalid effortLevel value', () => {
const found = result.findings.some(f => f.title === 'Invalid effortLevel value');
assert.ok(found, 'Should detect effortLevel "turbo"');
});
it('detects hooks as array', () => {
const found = result.findings.some(f => f.title.includes('array instead of object'));
assert.ok(found, 'Should detect hooks array format');
});
it('marks hooks-as-array as critical', () => {
const f = result.findings.find(f => f.title.includes('array instead of object'));
assert.strictEqual(f?.severity, 'critical');
});
});
describe('SET scanner — empty project', () => {
let result;
beforeEach(async () => {
resetCounter();
const discovery = await discoverConfigFiles(resolve(FIXTURES, 'empty-project'));
result = await scan(resolve(FIXTURES, 'empty-project'), discovery);
});
it('returns skipped when no settings files', () => {
assert.strictEqual(result.status, 'skipped');
});
it('has 0 findings', () => {
assert.strictEqual(result.findings.length, 0);
});
});