feat: initial open marketplace with llm-security, config-audit, ultraplan-local
This commit is contained in:
commit
f93d6abdae
380 changed files with 65935 additions and 0 deletions
3
plugins/config-audit/tests/fixtures/broken-plugin/.claude-plugin/plugin.json
vendored
Normal file
3
plugins/config-audit/tests/fixtures/broken-plugin/.claude-plugin/plugin.json
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"name": "broken-plugin"
|
||||
}
|
||||
8
plugins/config-audit/tests/fixtures/broken-plugin/agents/bad-agent.md
vendored
Normal file
8
plugins/config-audit/tests/fixtures/broken-plugin/agents/bad-agent.md
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
---
|
||||
name: bad-agent
|
||||
description: Missing model and tools
|
||||
---
|
||||
|
||||
# Bad Agent
|
||||
|
||||
No model or tools in frontmatter.
|
||||
3
plugins/config-audit/tests/fixtures/broken-plugin/commands/no-frontmatter.md
vendored
Normal file
3
plugins/config-audit/tests/fixtures/broken-plugin/commands/no-frontmatter.md
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# A command without frontmatter
|
||||
|
||||
This command has no YAML frontmatter.
|
||||
60
plugins/config-audit/tests/fixtures/broken-project/.claude/rules/big-unscoped.md
vendored
Normal file
60
plugins/config-audit/tests/fixtures/broken-project/.claude/rules/big-unscoped.md
vendored
Normal 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.
|
||||
6
plugins/config-audit/tests/fixtures/broken-project/.claude/rules/dead-rule.md
vendored
Normal file
6
plugins/config-audit/tests/fixtures/broken-project/.claude/rules/dead-rule.md
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
globs: nonexistent-dir/**/*.xyz
|
||||
---
|
||||
|
||||
# Dead Rule
|
||||
This rule matches nothing.
|
||||
7
plugins/config-audit/tests/fixtures/broken-project/.claude/settings.json
vendored
Normal file
7
plugins/config-audit/tests/fixtures/broken-project/.claude/settings.json
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"includeCoAuthoredBy": true,
|
||||
"alwaysThinkingEnabled": "yes",
|
||||
"effortLevel": "turbo",
|
||||
"unknownKey123": true,
|
||||
"hooks": ["not", "an", "object"]
|
||||
}
|
||||
24
plugins/config-audit/tests/fixtures/broken-project/.mcp.json
vendored
Normal file
24
plugins/config-audit/tests/fixtures/broken-project/.mcp.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
262
plugins/config-audit/tests/fixtures/broken-project/CLAUDE.md
vendored
Normal file
262
plugins/config-audit/tests/fixtures/broken-project/CLAUDE.md
vendored
Normal 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
|
||||
26
plugins/config-audit/tests/fixtures/broken-project/hooks/hooks.json
vendored
Normal file
26
plugins/config-audit/tests/fixtures/broken-project/hooks/hooks.json
vendored
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
3
plugins/config-audit/tests/fixtures/broken-project/imports/a.md
vendored
Normal file
3
plugins/config-audit/tests/fixtures/broken-project/imports/a.md
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Import A
|
||||
Shared content from file A.
|
||||
@b.md
|
||||
3
plugins/config-audit/tests/fixtures/broken-project/imports/b.md
vendored
Normal file
3
plugins/config-audit/tests/fixtures/broken-project/imports/b.md
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Import B
|
||||
Shared content from file B.
|
||||
@a.md
|
||||
16
plugins/config-audit/tests/fixtures/conflict-project/.claude/settings.json
vendored
Normal file
16
plugins/config-audit/tests/fixtures/conflict-project/.claude/settings.json
vendored
Normal 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" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
7
plugins/config-audit/tests/fixtures/conflict-project/CLAUDE.md
vendored
Normal file
7
plugins/config-audit/tests/fixtures/conflict-project/CLAUDE.md
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# Conflict Test Project
|
||||
|
||||
## Overview
|
||||
A test project with intentional configuration conflicts across scopes.
|
||||
|
||||
## Commands
|
||||
- `npm test` — Run tests
|
||||
2
plugins/config-audit/tests/fixtures/fixable-project/.claude/rules/readme.txt
vendored
Normal file
2
plugins/config-audit/tests/fixtures/fixable-project/.claude/rules/readme.txt
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
This rule file has the wrong extension.
|
||||
It should be .md to be loaded by Claude Code.
|
||||
8
plugins/config-audit/tests/fixtures/fixable-project/.claude/rules/typescript.md
vendored
Normal file
8
plugins/config-audit/tests/fixtures/fixable-project/.claude/rules/typescript.md
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
---
|
||||
globs: "**/*.ts"
|
||||
---
|
||||
|
||||
# TypeScript Rules
|
||||
|
||||
- Use strict mode
|
||||
- Prefer interfaces over types
|
||||
14
plugins/config-audit/tests/fixtures/fixable-project/.claude/settings.json
vendored
Normal file
14
plugins/config-audit/tests/fixtures/fixable-project/.claude/settings.json
vendored
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"apiProvider": "anthropic",
|
||||
"permissions": {
|
||||
"allow": []
|
||||
},
|
||||
"alwaysThinkingEnabled": "true",
|
||||
"effortLevel": "turbo",
|
||||
"hooks": [
|
||||
{
|
||||
"event": "PreToolUse",
|
||||
"command": "echo ok"
|
||||
}
|
||||
]
|
||||
}
|
||||
2
plugins/config-audit/tests/fixtures/fixable-project/.config-audit-ignore
vendored
Normal file
2
plugins/config-audit/tests/fixtures/fixable-project/.config-audit-ignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# Suppress known feature gap findings for this test fixture
|
||||
CA-GAP-*
|
||||
7
plugins/config-audit/tests/fixtures/fixable-project/CLAUDE.md
vendored
Normal file
7
plugins/config-audit/tests/fixtures/fixable-project/CLAUDE.md
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# Fixable Project
|
||||
|
||||
This is a minimal CLAUDE.md for the fixable-project fixture.
|
||||
|
||||
## Rules
|
||||
|
||||
- Follow TypeScript conventions
|
||||
18
plugins/config-audit/tests/fixtures/fixable-project/hooks/hooks.json
vendored
Normal file
18
plugins/config-audit/tests/fixtures/fixable-project/hooks/hooks.json
vendored
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": {
|
||||
"tool": "Bash"
|
||||
},
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "echo ok",
|
||||
"timeout": "5000"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
6
plugins/config-audit/tests/fixtures/healthy-project/.claude/rules/typescript.md
vendored
Normal file
6
plugins/config-audit/tests/fixtures/healthy-project/.claude/rules/typescript.md
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
paths: src/**/*.ts
|
||||
---
|
||||
|
||||
# TypeScript Rules
|
||||
Use strict TypeScript. No `any` types.
|
||||
7
plugins/config-audit/tests/fixtures/healthy-project/.claude/settings.json
vendored
Normal file
7
plugins/config-audit/tests/fixtures/healthy-project/.claude/settings.json
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/claude-code-settings.json",
|
||||
"permissions": {
|
||||
"allow": ["Bash(npm run *)"],
|
||||
"deny": ["Read(./.env)"]
|
||||
}
|
||||
}
|
||||
7
plugins/config-audit/tests/fixtures/healthy-project/.claude/shared.md
vendored
Normal file
7
plugins/config-audit/tests/fixtures/healthy-project/.claude/shared.md
vendored
Normal 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
|
||||
16
plugins/config-audit/tests/fixtures/healthy-project/.mcp.json
vendored
Normal file
16
plugins/config-audit/tests/fixtures/healthy-project/.mcp.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
17
plugins/config-audit/tests/fixtures/healthy-project/CLAUDE.md
vendored
Normal file
17
plugins/config-audit/tests/fixtures/healthy-project/CLAUDE.md
vendored
Normal 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
|
||||
16
plugins/config-audit/tests/fixtures/healthy-project/hooks/hooks.json
vendored
Normal file
16
plugins/config-audit/tests/fixtures/healthy-project/hooks/hooks.json
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "echo ok",
|
||||
"timeout": 5000
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
1
plugins/config-audit/tests/fixtures/healthy-project/src/index.ts
vendored
Normal file
1
plugins/config-audit/tests/fixtures/healthy-project/src/index.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
export {};
|
||||
1
plugins/config-audit/tests/fixtures/minimal-project/CLAUDE.md
vendored
Normal file
1
plugins/config-audit/tests/fixtures/minimal-project/CLAUDE.md
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
# Project
|
||||
5
plugins/config-audit/tests/fixtures/test-plugin/.claude-plugin/plugin.json
vendored
Normal file
5
plugins/config-audit/tests/fixtures/test-plugin/.claude-plugin/plugin.json
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"name": "test-plugin",
|
||||
"description": "A test plugin for config-audit plugin-health scanner",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
21
plugins/config-audit/tests/fixtures/test-plugin/CLAUDE.md
vendored
Normal file
21
plugins/config-audit/tests/fixtures/test-plugin/CLAUDE.md
vendored
Normal 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 |
|
||||
10
plugins/config-audit/tests/fixtures/test-plugin/agents/test-agent.md
vendored
Normal file
10
plugins/config-audit/tests/fixtures/test-plugin/agents/test-agent.md
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
---
|
||||
name: test-agent
|
||||
description: A test agent for validation
|
||||
model: sonnet
|
||||
tools: ["Read", "Glob"]
|
||||
---
|
||||
|
||||
# Test Agent
|
||||
|
||||
A test agent.
|
||||
10
plugins/config-audit/tests/fixtures/test-plugin/commands/test-cmd.md
vendored
Normal file
10
plugins/config-audit/tests/fixtures/test-plugin/commands/test-cmd.md
vendored
Normal 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.
|
||||
15
plugins/config-audit/tests/fixtures/test-plugin/hooks/hooks.json
vendored
Normal file
15
plugins/config-audit/tests/fixtures/test-plugin/hooks/hooks.json
vendored
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "echo test"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
87
plugins/config-audit/tests/hooks/post-edit-verify.test.mjs
Normal file
87
plugins/config-audit/tests/hooks/post-edit-verify.test.mjs
Normal 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 \\\\');
|
||||
});
|
||||
});
|
||||
176
plugins/config-audit/tests/lib/baseline.test.mjs
Normal file
176
plugins/config-audit/tests/lib/baseline.test.mjs
Normal 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);
|
||||
});
|
||||
});
|
||||
288
plugins/config-audit/tests/lib/diff-engine.test.mjs
Normal file
288
plugins/config-audit/tests/lib/diff-engine.test.mjs
Normal 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'));
|
||||
});
|
||||
});
|
||||
391
plugins/config-audit/tests/lib/file-discovery.test.mjs
Normal file
391
plugins/config-audit/tests/lib/file-discovery.test.mjs
Normal 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);
|
||||
});
|
||||
});
|
||||
149
plugins/config-audit/tests/lib/output.test.mjs
Normal file
149
plugins/config-audit/tests/lib/output.test.mjs
Normal 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');
|
||||
});
|
||||
});
|
||||
252
plugins/config-audit/tests/lib/report-generator.test.mjs
Normal file
252
plugins/config-audit/tests/lib/report-generator.test.mjs
Normal 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
|
||||
});
|
||||
});
|
||||
545
plugins/config-audit/tests/lib/scoring.test.mjs
Normal file
545
plugins/config-audit/tests/lib/scoring.test.mjs
Normal 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);
|
||||
});
|
||||
});
|
||||
133
plugins/config-audit/tests/lib/severity.test.mjs
Normal file
133
plugins/config-audit/tests/lib/severity.test.mjs
Normal 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);
|
||||
});
|
||||
});
|
||||
116
plugins/config-audit/tests/lib/string-utils.test.mjs
Normal file
116
plugins/config-audit/tests/lib/string-utils.test.mjs
Normal 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');
|
||||
});
|
||||
});
|
||||
199
plugins/config-audit/tests/lib/suppression.test.mjs
Normal file
199
plugins/config-audit/tests/lib/suppression.test.mjs
Normal 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 });
|
||||
});
|
||||
});
|
||||
147
plugins/config-audit/tests/lib/yaml-parser.test.mjs
Normal file
147
plugins/config-audit/tests/lib/yaml-parser.test.mjs
Normal 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);
|
||||
});
|
||||
});
|
||||
110
plugins/config-audit/tests/scanners/claude-md-linter.test.mjs
Normal file
110
plugins/config-audit/tests/scanners/claude-md-linter.test.mjs
Normal 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');
|
||||
});
|
||||
});
|
||||
124
plugins/config-audit/tests/scanners/conflict-detector.test.mjs
Normal file
124
plugins/config-audit/tests/scanners/conflict-detector.test.mjs
Normal 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);
|
||||
});
|
||||
});
|
||||
100
plugins/config-audit/tests/scanners/drift-cli.test.mjs
Normal file
100
plugins/config-audit/tests/scanners/drift-cli.test.mjs
Normal 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;
|
||||
});
|
||||
});
|
||||
});
|
||||
199
plugins/config-audit/tests/scanners/feature-gap-scanner.test.mjs
Normal file
199
plugins/config-audit/tests/scanners/feature-gap-scanner.test.mjs
Normal 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);
|
||||
});
|
||||
});
|
||||
91
plugins/config-audit/tests/scanners/fix-cli.test.mjs
Normal file
91
plugins/config-audit/tests/scanners/fix-cli.test.mjs
Normal 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');
|
||||
});
|
||||
});
|
||||
305
plugins/config-audit/tests/scanners/fix-engine.test.mjs
Normal file
305
plugins/config-audit/tests/scanners/fix-engine.test.mjs
Normal 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');
|
||||
});
|
||||
});
|
||||
86
plugins/config-audit/tests/scanners/hook-validator.test.mjs
Normal file
86
plugins/config-audit/tests/scanners/hook-validator.test.mjs
Normal 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);
|
||||
});
|
||||
});
|
||||
117
plugins/config-audit/tests/scanners/import-resolver.test.mjs
Normal file
117
plugins/config-audit/tests/scanners/import-resolver.test.mjs
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
123
plugins/config-audit/tests/scanners/posture.test.mjs
Normal file
123
plugins/config-audit/tests/scanners/posture.test.mjs
Normal 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');
|
||||
});
|
||||
});
|
||||
128
plugins/config-audit/tests/scanners/rollback-engine.test.mjs
Normal file
128
plugins/config-audit/tests/scanners/rollback-engine.test.mjs
Normal 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');
|
||||
});
|
||||
});
|
||||
84
plugins/config-audit/tests/scanners/rules-validator.test.mjs
Normal file
84
plugins/config-audit/tests/scanners/rules-validator.test.mjs
Normal 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);
|
||||
});
|
||||
});
|
||||
172
plugins/config-audit/tests/scanners/scan-orchestrator.test.mjs
Normal file
172
plugins/config-audit/tests/scanners/scan-orchestrator.test.mjs
Normal 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');
|
||||
}
|
||||
});
|
||||
});
|
||||
90
plugins/config-audit/tests/scanners/self-audit.test.mjs
Normal file
90
plugins/config-audit/tests/scanners/self-audit.test.mjs
Normal 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'));
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue