@vibe-agent-toolkit/cli 0.2.0-rc.1 → 0.2.0-rc.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -5
- package/dist/bin/vat +97 -28
- package/dist/bin/vat.js +97 -28
- package/dist/bin/vat.js.map +1 -1
- package/dist/bin.js +115 -76
- package/dist/bin.js.map +1 -1
- package/dist/command-loaders.d.ts +44 -0
- package/dist/command-loaders.d.ts.map +1 -0
- package/dist/command-loaders.js +60 -0
- package/dist/command-loaders.js.map +1 -0
- package/dist/commands/agent/build.d.ts.map +1 -1
- package/dist/commands/agent/build.js +2 -1
- package/dist/commands/agent/build.js.map +1 -1
- package/dist/commands/agent/import.d.ts.map +1 -1
- package/dist/commands/agent/import.js +3 -2
- package/dist/commands/agent/import.js.map +1 -1
- package/dist/commands/agent/index.js +1 -1
- package/dist/commands/agent/install-path.d.ts +36 -0
- package/dist/commands/agent/install-path.d.ts.map +1 -0
- package/dist/commands/agent/install-path.js +44 -0
- package/dist/commands/agent/install-path.js.map +1 -0
- package/dist/commands/agent/install.d.ts.map +1 -1
- package/dist/commands/agent/install.js +66 -13
- package/dist/commands/agent/install.js.map +1 -1
- package/dist/commands/agent/installed.d.ts.map +1 -1
- package/dist/commands/agent/installed.js +22 -12
- package/dist/commands/agent/installed.js.map +1 -1
- package/dist/commands/agent/list.d.ts.map +1 -1
- package/dist/commands/agent/list.js +2 -1
- package/dist/commands/agent/list.js.map +1 -1
- package/dist/commands/agent/run.d.ts.map +1 -1
- package/dist/commands/agent/run.js +2 -1
- package/dist/commands/agent/run.js.map +1 -1
- package/dist/commands/agent/uninstall.d.ts.map +1 -1
- package/dist/commands/agent/uninstall.js +13 -6
- package/dist/commands/agent/uninstall.js.map +1 -1
- package/dist/commands/agent/validate.d.ts.map +1 -1
- package/dist/commands/agent/validate.js +3 -2
- package/dist/commands/agent/validate.js.map +1 -1
- package/dist/commands/ard/emit.d.ts +165 -0
- package/dist/commands/ard/emit.d.ts.map +1 -0
- package/dist/commands/ard/emit.js +338 -0
- package/dist/commands/ard/emit.js.map +1 -0
- package/dist/commands/ard/index.d.ts +12 -0
- package/dist/commands/ard/index.d.ts.map +1 -0
- package/dist/commands/ard/index.js +84 -0
- package/dist/commands/ard/index.js.map +1 -0
- package/dist/commands/ard/surfaces.d.ts +56 -0
- package/dist/commands/ard/surfaces.d.ts.map +1 -0
- package/dist/commands/ard/surfaces.js +150 -0
- package/dist/commands/ard/surfaces.js.map +1 -0
- package/dist/commands/audit/distributed-tree.d.ts +2 -2
- package/dist/commands/audit/distributed-tree.d.ts.map +1 -1
- package/dist/commands/audit/distributed-tree.js +11 -7
- package/dist/commands/audit/distributed-tree.js.map +1 -1
- package/dist/commands/audit/git-url-clone.d.ts +1 -1
- package/dist/commands/audit/git-url-clone.d.ts.map +1 -1
- package/dist/commands/audit/git-url-clone.js.map +1 -1
- package/dist/commands/audit/hierarchical-output.d.ts +1 -1
- package/dist/commands/audit/hierarchical-output.d.ts.map +1 -1
- package/dist/commands/audit/hierarchical-output.js +1 -1
- package/dist/commands/audit/hierarchical-output.js.map +1 -1
- package/dist/commands/audit/provenance.d.ts.map +1 -1
- package/dist/commands/audit/provenance.js +4 -3
- package/dist/commands/audit/provenance.js.map +1 -1
- package/dist/commands/audit/scan-population.d.ts +158 -0
- package/dist/commands/audit/scan-population.d.ts.map +1 -0
- package/dist/commands/audit/scan-population.js +292 -0
- package/dist/commands/audit/scan-population.js.map +1 -0
- package/dist/commands/audit-settings.d.ts.map +1 -1
- package/dist/commands/audit-settings.js +6 -5
- package/dist/commands/audit-settings.js.map +1 -1
- package/dist/commands/audit.d.ts +98 -8
- package/dist/commands/audit.d.ts.map +1 -1
- package/dist/commands/audit.js +1150 -394
- package/dist/commands/audit.js.map +1 -1
- package/dist/commands/build.d.ts +1 -1
- package/dist/commands/build.d.ts.map +1 -1
- package/dist/commands/build.js +40 -36
- package/dist/commands/build.js.map +1 -1
- package/dist/commands/cache/cache-control.d.ts +82 -0
- package/dist/commands/cache/cache-control.d.ts.map +1 -0
- package/dist/commands/cache/cache-control.js +86 -0
- package/dist/commands/cache/cache-control.js.map +1 -0
- package/dist/commands/cache/clear.d.ts +34 -6
- package/dist/commands/cache/clear.d.ts.map +1 -1
- package/dist/commands/cache/clear.js +101 -15
- package/dist/commands/cache/clear.js.map +1 -1
- package/dist/commands/cache/index.d.ts +13 -63
- package/dist/commands/cache/index.d.ts.map +1 -1
- package/dist/commands/cache/index.js +47 -79
- package/dist/commands/cache/index.js.map +1 -1
- package/dist/commands/claude/claude-config.d.ts.map +1 -1
- package/dist/commands/claude/claude-config.js +6 -0
- package/dist/commands/claude/claude-config.js.map +1 -1
- package/dist/commands/claude/context.d.ts +372 -0
- package/dist/commands/claude/context.d.ts.map +1 -0
- package/dist/commands/claude/context.js +1115 -0
- package/dist/commands/claude/context.js.map +1 -0
- package/dist/commands/claude/index.d.ts +14 -0
- package/dist/commands/claude/index.d.ts.map +1 -1
- package/dist/commands/claude/index.js +22 -2
- package/dist/commands/claude/index.js.map +1 -1
- package/dist/commands/claude/marketplace/changelog-utils.d.ts +1 -1
- package/dist/commands/claude/marketplace/changelog-utils.d.ts.map +1 -1
- package/dist/commands/claude/marketplace/changelog-utils.js +1 -2
- package/dist/commands/claude/marketplace/changelog-utils.js.map +1 -1
- package/dist/commands/claude/marketplace/git-publish.d.ts.map +1 -1
- package/dist/commands/claude/marketplace/git-publish.js +40 -31
- package/dist/commands/claude/marketplace/git-publish.js.map +1 -1
- package/dist/commands/claude/marketplace/license-utils.d.ts.map +1 -1
- package/dist/commands/claude/marketplace/license-utils.js +0 -1
- package/dist/commands/claude/marketplace/license-utils.js.map +1 -1
- package/dist/commands/claude/marketplace/publish-tree.d.ts.map +1 -1
- package/dist/commands/claude/marketplace/publish-tree.js +0 -6
- package/dist/commands/claude/marketplace/publish-tree.js.map +1 -1
- package/dist/commands/claude/marketplace/publish.d.ts.map +1 -1
- package/dist/commands/claude/marketplace/publish.js +2 -1
- package/dist/commands/claude/marketplace/publish.js.map +1 -1
- package/dist/commands/claude/marketplace/validate.d.ts +73 -22
- package/dist/commands/claude/marketplace/validate.d.ts.map +1 -1
- package/dist/commands/claude/marketplace/validate.js +407 -72
- package/dist/commands/claude/marketplace/validate.js.map +1 -1
- package/dist/commands/claude/org/api-keys.d.ts +0 -3
- package/dist/commands/claude/org/api-keys.d.ts.map +1 -1
- package/dist/commands/claude/org/api-keys.js +2 -1
- package/dist/commands/claude/org/api-keys.js.map +1 -1
- package/dist/commands/claude/org/helpers.d.ts +54 -1
- package/dist/commands/claude/org/helpers.d.ts.map +1 -1
- package/dist/commands/claude/org/helpers.js +42 -7
- package/dist/commands/claude/org/helpers.js.map +1 -1
- package/dist/commands/claude/org/index.d.ts.map +1 -1
- package/dist/commands/claude/org/index.js +24 -16
- package/dist/commands/claude/org/index.js.map +1 -1
- package/dist/commands/claude/org/invites.d.ts +0 -3
- package/dist/commands/claude/org/invites.d.ts.map +1 -1
- package/dist/commands/claude/org/invites.js +3 -2
- package/dist/commands/claude/org/invites.js.map +1 -1
- package/dist/commands/claude/org/skills.d.ts +527 -0
- package/dist/commands/claude/org/skills.d.ts.map +1 -1
- package/dist/commands/claude/org/skills.js +1414 -108
- package/dist/commands/claude/org/skills.js.map +1 -1
- package/dist/commands/claude/org/users.d.ts +0 -3
- package/dist/commands/claude/org/users.d.ts.map +1 -1
- package/dist/commands/claude/org/users.js +6 -3
- package/dist/commands/claude/org/users.js.map +1 -1
- package/dist/commands/claude/org/workspaces.d.ts +0 -3
- package/dist/commands/claude/org/workspaces.d.ts.map +1 -1
- package/dist/commands/claude/org/workspaces.js +11 -7
- package/dist/commands/claude/org/workspaces.js.map +1 -1
- package/dist/commands/claude/plugin/build.d.ts +41 -3
- package/dist/commands/claude/plugin/build.d.ts.map +1 -1
- package/dist/commands/claude/plugin/build.js +147 -80
- package/dist/commands/claude/plugin/build.js.map +1 -1
- package/dist/commands/claude/plugin/helpers.d.ts.map +1 -1
- package/dist/commands/claude/plugin/helpers.js +2 -7
- package/dist/commands/claude/plugin/helpers.js.map +1 -1
- package/dist/commands/claude/plugin/install.d.ts +12 -0
- package/dist/commands/claude/plugin/install.d.ts.map +1 -1
- package/dist/commands/claude/plugin/install.js +93 -62
- package/dist/commands/claude/plugin/install.js.map +1 -1
- package/dist/commands/claude/plugin/list.d.ts.map +1 -1
- package/dist/commands/claude/plugin/list.js +3 -2
- package/dist/commands/claude/plugin/list.js.map +1 -1
- package/dist/commands/claude/plugin/marketplace-json.d.ts +24 -9
- package/dist/commands/claude/plugin/marketplace-json.d.ts.map +1 -1
- package/dist/commands/claude/plugin/marketplace-json.js +10 -9
- package/dist/commands/claude/plugin/marketplace-json.js.map +1 -1
- package/dist/commands/claude/plugin/plugin-changelog.d.ts.map +1 -1
- package/dist/commands/claude/plugin/plugin-changelog.js +0 -2
- package/dist/commands/claude/plugin/plugin-changelog.js.map +1 -1
- package/dist/commands/claude/plugin/plugin-files.d.ts.map +1 -1
- package/dist/commands/claude/plugin/plugin-files.js +3 -6
- package/dist/commands/claude/plugin/plugin-files.js.map +1 -1
- package/dist/commands/claude/plugin/plugin-validators.d.ts.map +1 -1
- package/dist/commands/claude/plugin/plugin-validators.js +3 -6
- package/dist/commands/claude/plugin/plugin-validators.js.map +1 -1
- package/dist/commands/claude/plugin/tree-copy.d.ts +73 -0
- package/dist/commands/claude/plugin/tree-copy.d.ts.map +1 -1
- package/dist/commands/claude/plugin/tree-copy.js +221 -14
- package/dist/commands/claude/plugin/tree-copy.js.map +1 -1
- package/dist/commands/claude/plugin/uninstall.d.ts.map +1 -1
- package/dist/commands/claude/plugin/uninstall.js +4 -4
- package/dist/commands/claude/plugin/uninstall.js.map +1 -1
- package/dist/commands/consistency-check.d.ts +17 -15
- package/dist/commands/consistency-check.d.ts.map +1 -1
- package/dist/commands/consistency-check.js +152 -106
- package/dist/commands/consistency-check.js.map +1 -1
- package/dist/commands/corpus/index.d.ts.map +1 -1
- package/dist/commands/corpus/index.js +3 -0
- package/dist/commands/corpus/index.js.map +1 -1
- package/dist/commands/corpus/report.d.ts +1 -2
- package/dist/commands/corpus/report.d.ts.map +1 -1
- package/dist/commands/corpus/report.js +1 -3
- package/dist/commands/corpus/report.js.map +1 -1
- package/dist/commands/corpus/runner.d.ts +58 -1
- package/dist/commands/corpus/runner.d.ts.map +1 -1
- package/dist/commands/corpus/runner.js +95 -41
- package/dist/commands/corpus/runner.js.map +1 -1
- package/dist/commands/corpus/scan.d.ts.map +1 -1
- package/dist/commands/corpus/scan.js +20 -12
- package/dist/commands/corpus/scan.js.map +1 -1
- package/dist/commands/corpus/seed.d.ts +4 -4
- package/dist/commands/corpus/seed.d.ts.map +1 -1
- package/dist/commands/corpus/seed.js +1 -3
- package/dist/commands/corpus/seed.js.map +1 -1
- package/dist/commands/doctor.d.ts +50 -0
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +245 -52
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/inventory.d.ts +13 -1
- package/dist/commands/inventory.d.ts.map +1 -1
- package/dist/commands/inventory.js +121 -17
- package/dist/commands/inventory.js.map +1 -1
- package/dist/commands/mcp/collections.d.ts +1 -1
- package/dist/commands/mcp/collections.d.ts.map +1 -1
- package/dist/commands/mcp/list-collections.d.ts.map +1 -1
- package/dist/commands/mcp/list-collections.js +2 -1
- package/dist/commands/mcp/list-collections.js.map +1 -1
- package/dist/commands/mcp/serve.d.ts.map +1 -1
- package/dist/commands/mcp/serve.js +3 -2
- package/dist/commands/mcp/serve.js.map +1 -1
- package/dist/commands/okf/index.d.ts +11 -0
- package/dist/commands/okf/index.d.ts.map +1 -0
- package/dist/commands/okf/index.js +116 -0
- package/dist/commands/okf/index.js.map +1 -0
- package/dist/commands/okf/validate.d.ts +195 -0
- package/dist/commands/okf/validate.d.ts.map +1 -0
- package/dist/commands/okf/validate.js +199 -0
- package/dist/commands/okf/validate.js.map +1 -0
- package/dist/commands/phase-utils.d.ts +182 -114
- package/dist/commands/phase-utils.d.ts.map +1 -1
- package/dist/commands/phase-utils.js +154 -163
- package/dist/commands/phase-utils.js.map +1 -1
- package/dist/commands/positional-args.d.ts.map +1 -1
- package/dist/commands/positional-args.js +3 -2
- package/dist/commands/positional-args.js.map +1 -1
- package/dist/commands/rag/clear-command.d.ts.map +1 -1
- package/dist/commands/rag/clear-command.js +2 -1
- package/dist/commands/rag/clear-command.js.map +1 -1
- package/dist/commands/rag/index-command.d.ts +64 -0
- package/dist/commands/rag/index-command.d.ts.map +1 -1
- package/dist/commands/rag/index-command.js +72 -6
- package/dist/commands/rag/index-command.js.map +1 -1
- package/dist/commands/rag/index.d.ts.map +1 -1
- package/dist/commands/rag/index.js +23 -10
- package/dist/commands/rag/index.js.map +1 -1
- package/dist/commands/rag/query-command.d.ts.map +1 -1
- package/dist/commands/rag/query-command.js +2 -1
- package/dist/commands/rag/query-command.js.map +1 -1
- package/dist/commands/rag/stats-command.d.ts.map +1 -1
- package/dist/commands/rag/stats-command.js +2 -1
- package/dist/commands/rag/stats-command.js.map +1 -1
- package/dist/commands/resources/builtin-help.d.ts +27 -0
- package/dist/commands/resources/builtin-help.d.ts.map +1 -0
- package/dist/commands/resources/builtin-help.js +46 -0
- package/dist/commands/resources/builtin-help.js.map +1 -0
- package/dist/commands/resources/check-progress.d.ts +167 -0
- package/dist/commands/resources/check-progress.d.ts.map +1 -0
- package/dist/commands/resources/check-progress.js +250 -0
- package/dist/commands/resources/check-progress.js.map +1 -0
- package/dist/commands/resources/check-supervisor.d.ts +479 -0
- package/dist/commands/resources/check-supervisor.d.ts.map +1 -0
- package/dist/commands/resources/check-supervisor.js +587 -0
- package/dist/commands/resources/check-supervisor.js.map +1 -0
- package/dist/commands/resources/check.d.ts +620 -0
- package/dist/commands/resources/check.d.ts.map +1 -0
- package/dist/commands/resources/check.js +1452 -0
- package/dist/commands/resources/check.js.map +1 -0
- package/dist/commands/resources/index.d.ts.map +1 -1
- package/dist/commands/resources/index.js +430 -4
- package/dist/commands/resources/index.js.map +1 -1
- package/dist/commands/resources/query.d.ts +106 -0
- package/dist/commands/resources/query.d.ts.map +1 -0
- package/dist/commands/resources/query.js +177 -0
- package/dist/commands/resources/query.js.map +1 -0
- package/dist/commands/resources/scan.d.ts +24 -0
- package/dist/commands/resources/scan.d.ts.map +1 -1
- package/dist/commands/resources/scan.js +17 -8
- package/dist/commands/resources/scan.js.map +1 -1
- package/dist/commands/resources/validate.d.ts +42 -2
- package/dist/commands/resources/validate.d.ts.map +1 -1
- package/dist/commands/resources/validate.js +208 -55
- package/dist/commands/resources/validate.js.map +1 -1
- package/dist/commands/skill/review-checklist.d.ts +3 -2
- package/dist/commands/skill/review-checklist.d.ts.map +1 -1
- package/dist/commands/skill/review-checklist.js +40 -2
- package/dist/commands/skill/review-checklist.js.map +1 -1
- package/dist/commands/skill/review.d.ts +247 -0
- package/dist/commands/skill/review.d.ts.map +1 -1
- package/dist/commands/skill/review.js +100 -62
- package/dist/commands/skill/review.js.map +1 -1
- package/dist/commands/skill/test/configure.d.ts +56 -0
- package/dist/commands/skill/test/configure.d.ts.map +1 -1
- package/dist/commands/skill/test/configure.js +75 -16
- package/dist/commands/skill/test/configure.js.map +1 -1
- package/dist/commands/skill/test/run.d.ts +63 -10
- package/dist/commands/skill/test/run.d.ts.map +1 -1
- package/dist/commands/skill/test/run.js +258 -77
- package/dist/commands/skill/test/run.js.map +1 -1
- package/dist/commands/skills/build.d.ts +94 -4
- package/dist/commands/skills/build.d.ts.map +1 -1
- package/dist/commands/skills/build.js +325 -97
- package/dist/commands/skills/build.js.map +1 -1
- package/dist/commands/skills/install.d.ts +2 -1
- package/dist/commands/skills/install.d.ts.map +1 -1
- package/dist/commands/skills/install.js +20 -31
- package/dist/commands/skills/install.js.map +1 -1
- package/dist/commands/skills/list.d.ts +2 -1
- package/dist/commands/skills/list.d.ts.map +1 -1
- package/dist/commands/skills/list.js +44 -26
- package/dist/commands/skills/list.js.map +1 -1
- package/dist/commands/skills/package.d.ts +1 -1
- package/dist/commands/skills/package.d.ts.map +1 -1
- package/dist/commands/skills/package.js +6 -8
- package/dist/commands/skills/package.js.map +1 -1
- package/dist/commands/skills/scope-guard.d.ts.map +1 -1
- package/dist/commands/skills/scope-guard.js +8 -6
- package/dist/commands/skills/scope-guard.js.map +1 -1
- package/dist/commands/skills/shared.d.ts.map +1 -1
- package/dist/commands/skills/shared.js +2 -4
- package/dist/commands/skills/shared.js.map +1 -1
- package/dist/commands/skills/skill-discovery.d.ts +47 -6
- package/dist/commands/skills/skill-discovery.d.ts.map +1 -1
- package/dist/commands/skills/skill-discovery.js +61 -11
- package/dist/commands/skills/skill-discovery.js.map +1 -1
- package/dist/commands/skills/source-resolvers.d.ts +15 -0
- package/dist/commands/skills/source-resolvers.d.ts.map +1 -1
- package/dist/commands/skills/source-resolvers.js +25 -4
- package/dist/commands/skills/source-resolvers.js.map +1 -1
- package/dist/commands/skills/validate-command.d.ts.map +1 -1
- package/dist/commands/skills/validate-command.js +11 -3
- package/dist/commands/skills/validate-command.js.map +1 -1
- package/dist/commands/skills/validate.d.ts +140 -4
- package/dist/commands/skills/validate.d.ts.map +1 -1
- package/dist/commands/skills/validate.js +224 -67
- package/dist/commands/skills/validate.js.map +1 -1
- package/dist/commands/validate.d.ts +1 -1
- package/dist/commands/validate.d.ts.map +1 -1
- package/dist/commands/validate.js +63 -27
- package/dist/commands/validate.js.map +1 -1
- package/dist/commands/verify.d.ts +119 -15
- package/dist/commands/verify.d.ts.map +1 -1
- package/dist/commands/verify.js +357 -142
- package/dist/commands/verify.js.map +1 -1
- package/dist/index.d.ts +0 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/report-schemas.d.ts +45 -0
- package/dist/report-schemas.d.ts.map +1 -0
- package/dist/report-schemas.js +100 -0
- package/dist/report-schemas.js.map +1 -0
- package/dist/skill-resolution/classify.js +2 -2
- package/dist/skill-resolution/classify.js.map +1 -1
- package/dist/skill-resolution/packaging-config.d.ts +49 -5
- package/dist/skill-resolution/packaging-config.d.ts.map +1 -1
- package/dist/skill-resolution/packaging-config.js +59 -19
- package/dist/skill-resolution/packaging-config.js.map +1 -1
- package/dist/skill-resolution/resolve-skill-reference.d.ts.map +1 -1
- package/dist/skill-resolution/resolve-skill-reference.js +55 -57
- package/dist/skill-resolution/resolve-skill-reference.js.map +1 -1
- package/dist/utils/agent-discovery.d.ts.map +1 -1
- package/dist/utils/agent-discovery.js +22 -11
- package/dist/utils/agent-discovery.js.map +1 -1
- package/dist/utils/agent-runner.d.ts.map +1 -1
- package/dist/utils/agent-runner.js +7 -4
- package/dist/utils/agent-runner.js.map +1 -1
- package/dist/utils/claude-context-lens.d.ts +34 -0
- package/dist/utils/claude-context-lens.d.ts.map +1 -0
- package/dist/utils/claude-context-lens.js +39 -0
- package/dist/utils/claude-context-lens.js.map +1 -0
- package/dist/utils/command-error.d.ts +143 -20
- package/dist/utils/command-error.d.ts.map +1 -1
- package/dist/utils/command-error.js +179 -36
- package/dist/utils/command-error.js.map +1 -1
- package/dist/utils/config-loader.d.ts +2 -1
- package/dist/utils/config-loader.d.ts.map +1 -1
- package/dist/utils/config-loader.js +40 -14
- package/dist/utils/config-loader.js.map +1 -1
- package/dist/utils/corpus-target.d.ts +26 -0
- package/dist/utils/corpus-target.d.ts.map +1 -0
- package/dist/utils/corpus-target.js +72 -0
- package/dist/utils/corpus-target.js.map +1 -0
- package/dist/utils/edge-lens-evaluation.d.ts +83 -0
- package/dist/utils/edge-lens-evaluation.d.ts.map +1 -0
- package/dist/utils/edge-lens-evaluation.js +110 -0
- package/dist/utils/edge-lens-evaluation.js.map +1 -0
- package/dist/utils/help-loader.d.ts.map +1 -1
- package/dist/utils/help-loader.js +0 -1
- package/dist/utils/help-loader.js.map +1 -1
- package/dist/utils/issue-anchor.d.ts +2 -2
- package/dist/utils/issue-anchor.d.ts.map +1 -1
- package/dist/utils/issue-anchor.js +1 -1
- package/dist/utils/issue-rendering.d.ts +3 -3
- package/dist/utils/issue-rendering.d.ts.map +1 -1
- package/dist/utils/issue-rendering.js +3 -3
- package/dist/utils/issue-rendering.js.map +1 -1
- package/dist/utils/issue-severity.d.ts +12 -5
- package/dist/utils/issue-severity.d.ts.map +1 -1
- package/dist/utils/issue-severity.js +1 -1
- package/dist/utils/issue-severity.js.map +1 -1
- package/dist/utils/optional-backend.d.ts +78 -0
- package/dist/utils/optional-backend.d.ts.map +1 -0
- package/dist/utils/optional-backend.js +117 -0
- package/dist/utils/optional-backend.js.map +1 -0
- package/dist/utils/output.d.ts +26 -0
- package/dist/utils/output.d.ts.map +1 -1
- package/dist/utils/output.js +35 -0
- package/dist/utils/output.js.map +1 -1
- package/dist/utils/package-json.d.ts +24 -0
- package/dist/utils/package-json.d.ts.map +1 -0
- package/dist/utils/package-json.js +49 -0
- package/dist/utils/package-json.js.map +1 -0
- package/dist/utils/population-wiring.d.ts +100 -0
- package/dist/utils/population-wiring.d.ts.map +1 -0
- package/dist/utils/population-wiring.js +98 -0
- package/dist/utils/population-wiring.js.map +1 -0
- package/dist/utils/project-root-policy.d.ts +20 -0
- package/dist/utils/project-root-policy.d.ts.map +1 -1
- package/dist/utils/project-root-policy.js +30 -0
- package/dist/utils/project-root-policy.js.map +1 -1
- package/dist/utils/projection-lenses.d.ts +98 -0
- package/dist/utils/projection-lenses.d.ts.map +1 -0
- package/dist/utils/projection-lenses.js +203 -0
- package/dist/utils/projection-lenses.js.map +1 -0
- package/dist/utils/projection-query.d.ts +324 -0
- package/dist/utils/projection-query.d.ts.map +1 -0
- package/dist/utils/projection-query.js +357 -0
- package/dist/utils/projection-query.js.map +1 -0
- package/dist/utils/projection-store.d.ts +335 -0
- package/dist/utils/projection-store.d.ts.map +1 -0
- package/dist/utils/projection-store.js +522 -0
- package/dist/utils/projection-store.js.map +1 -0
- package/dist/utils/relation-limits.d.ts +50 -0
- package/dist/utils/relation-limits.d.ts.map +1 -0
- package/dist/utils/relation-limits.js +50 -0
- package/dist/utils/relation-limits.js.map +1 -0
- package/dist/utils/repeatable-option.d.ts +37 -0
- package/dist/utils/repeatable-option.d.ts.map +1 -0
- package/dist/utils/repeatable-option.js +39 -0
- package/dist/utils/repeatable-option.js.map +1 -0
- package/dist/utils/resource-loader.d.ts +134 -2
- package/dist/utils/resource-loader.d.ts.map +1 -1
- package/dist/utils/resource-loader.js +290 -27
- package/dist/utils/resource-loader.js.map +1 -1
- package/dist/utils/root-argv.d.ts +53 -0
- package/dist/utils/root-argv.d.ts.map +1 -0
- package/dist/utils/root-argv.js +215 -0
- package/dist/utils/root-argv.js.map +1 -0
- package/dist/utils/run-integrity.d.ts +95 -0
- package/dist/utils/run-integrity.d.ts.map +1 -0
- package/dist/utils/run-integrity.js +102 -0
- package/dist/utils/run-integrity.js.map +1 -0
- package/dist/utils/skill-discovery.d.ts.map +1 -1
- package/dist/utils/skill-discovery.js +2 -1
- package/dist/utils/skill-discovery.js.map +1 -1
- package/dist/utils/skill-packaging-config.d.ts +63 -1
- package/dist/utils/skill-packaging-config.d.ts.map +1 -1
- package/dist/utils/skill-packaging-config.js +56 -1
- package/dist/utils/skill-packaging-config.js.map +1 -1
- package/dist/utils/sqlite-experimental-warning.d.ts +69 -0
- package/dist/utils/sqlite-experimental-warning.d.ts.map +1 -0
- package/dist/utils/sqlite-experimental-warning.js +144 -0
- package/dist/utils/sqlite-experimental-warning.js.map +1 -0
- package/dist/utils/url-redact.d.ts.map +1 -1
- package/dist/utils/url-redact.js +4 -6
- package/dist/utils/url-redact.js.map +1 -1
- package/dist/utils/user-context-scanner.d.ts +8 -0
- package/dist/utils/user-context-scanner.d.ts.map +1 -1
- package/dist/utils/user-context-scanner.js +4 -2
- package/dist/utils/user-context-scanner.js.map +1 -1
- package/dist/utils/validate-help-files.d.ts.map +1 -1
- package/dist/utils/validate-help-files.js +12 -6
- package/dist/utils/validate-help-files.js.map +1 -1
- package/dist/utils/vat-bin-path.d.ts +36 -0
- package/dist/utils/vat-bin-path.d.ts.map +1 -0
- package/dist/utils/vat-bin-path.js +48 -0
- package/dist/utils/vat-bin-path.js.map +1 -0
- package/dist/utils/verdict-helpers.d.ts +1 -1
- package/dist/utils/verdict-helpers.d.ts.map +1 -1
- package/dist/utils/verdict-helpers.js +1 -1
- package/dist/utils/verdict-helpers.js.map +1 -1
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +1 -3
- package/dist/version.js.map +1 -1
- package/docs/agent.md +2 -2
- package/docs/audit.md +143 -39
- package/docs/doctor.md +7 -6
- package/docs/index.md +19 -11
- package/docs/mcp.md +2 -2
- package/docs/rag.md +3 -2
- package/docs/resources.md +13 -3
- package/docs/skill-test.md +10 -9
- package/docs/skills.md +53 -69
- package/package.json +36 -27
- package/schemas/ard-emit.json +152 -0
- package/schemas/okf-validate.json +170 -0
- package/schemas/resources-check.json +257 -0
- package/schemas/skill-review.json +230 -0
- package/dist/commands/resources/command-helpers.d.ts +0 -5
- package/dist/commands/resources/command-helpers.d.ts.map +0 -1
- package/dist/commands/resources/command-helpers.js +0 -6
- package/dist/commands/resources/command-helpers.js.map +0 -1
package/dist/commands/audit.js
CHANGED
|
@@ -5,21 +5,24 @@
|
|
|
5
5
|
import * as fs from 'node:fs';
|
|
6
6
|
import { existsSync as fsExistsSync } from 'node:fs';
|
|
7
7
|
import { basename } from 'node:path';
|
|
8
|
-
import {
|
|
9
|
-
import { crawlAndResolveRegistry, detectDeclaredButMissing, detectMarketplacePluginSourceMissing, resetPackagingRegistryCache, detectPresentButUndeclared, detectReferenceTargetMissing, detectResourceFormat, enumerateSurfaces, validate, validateMarketplace, validateSkill, validateSkillForPackaging, } from '@vibe-agent-toolkit/agent-skills';
|
|
8
|
+
import { conventionalSuiteProbe, crawlAndResolveRegistry, detectDeclaredButMissing, detectMarketplacePluginSourceMissing, resetPackagingRegistryCache, detectPresentButUndeclared, detectReferenceTargetMissing, detectResourceFormat, enumerateSurfaces, materializeIssue, validate, validateMarketplace, validateSkill, validateSkillForPackaging, } from '@vibe-agent-toolkit/agent-skills';
|
|
10
9
|
import { analyzeCompatibility, checkSettingsCompatibility, detectSkillClaudePluginNameMismatch, crawlSkillLinkRegistry, extractClaudeMarketplaceInventory, extractClaudePluginInventory, getClaudeUserPaths, readEffectiveSettings, validatePlugin, } from '@vibe-agent-toolkit/claude-marketplace';
|
|
11
10
|
import { detectFormat } from '@vibe-agent-toolkit/discovery';
|
|
12
|
-
import {
|
|
11
|
+
import { listingRefusalRemedy } from '@vibe-agent-toolkit/resources';
|
|
12
|
+
import { calculateValidationStatus, countBySeverity, ExitCode, } from '@vibe-agent-toolkit/schema';
|
|
13
|
+
import { findProjectRoot, isFilesystemAccessError, isPathAbsentError, issueLocation, relativeEscapesRoot, resetProjectRootCaches, safePath, toForwardSlash, } from '@vibe-agent-toolkit/utils';
|
|
14
|
+
import { DirectoryListingRefusedError } from '@vibe-agent-toolkit/utils/crawl';
|
|
15
|
+
import { gitFindRoot, isGitUrl, parseGitUrl, } from '@vibe-agent-toolkit/utils/git';
|
|
13
16
|
import { Command } from 'commander';
|
|
14
|
-
import picomatch from 'picomatch';
|
|
15
17
|
import { resetSkillDiscoveryCache, resolveProjectDeclaredEvalSuites, resolveSkillPackagingConfig, stripValidationAllowForDisplay, } from '../skill-resolution/packaging-config.js';
|
|
16
|
-
import { handleCommandError } from '../utils/command-error.js';
|
|
18
|
+
import { handleCommandError, handleExpectedFailure } from '../utils/command-error.js';
|
|
17
19
|
import { ConfigLoadError, loadConfig, resetLoadedConfigCache, } from '../utils/config-loader.js';
|
|
18
|
-
import { countCollapsedFindings, formatCollapsedFindingsHint, formatIssueLines, formatSeverityBreakdown, issuesToRenderAtVerbosity, } from '../utils/issue-rendering.js';
|
|
20
|
+
import { countCollapsedFindings, formatCollapsedFindingsHint, formatIssueLines, formatSeverityBreakdown, issuesToRenderAtVerbosity, sumSeverityCounts, } from '../utils/issue-rendering.js';
|
|
19
21
|
import { resolveIssueSeverity } from '../utils/issue-severity.js';
|
|
20
22
|
import { createLogger } from '../utils/logger.js';
|
|
21
23
|
import { writeYamlOutput } from '../utils/output.js';
|
|
22
24
|
import { relativizePathEntries } from '../utils/relativize-paths.js';
|
|
25
|
+
import { nothingCheckedFinding } from '../utils/run-integrity.js';
|
|
23
26
|
import { mergeSkillPackagingConfig } from '../utils/skill-packaging-config.js';
|
|
24
27
|
import { renderSkillQualityFooter } from '../utils/skill-quality-footer.js';
|
|
25
28
|
import { computeConfigVerdicts } from '../utils/verdict-helpers.js';
|
|
@@ -27,13 +30,13 @@ import { crawlOwnsSubtree, distributedTreeFindings, getOrCreateGitTracker, gitTr
|
|
|
27
30
|
import { withClonedRepo } from './audit/git-url-clone.js';
|
|
28
31
|
import { buildHierarchicalOutput } from './audit/hierarchical-output.js';
|
|
29
32
|
import { renderProvenanceHeader, rewritePathsInResults, } from './audit/provenance.js';
|
|
33
|
+
import { enumerateAuditPopulation, resolveProjectExcludes, VAT_CONFIG_FILENAME, } from './audit/scan-population.js';
|
|
30
34
|
import { createAuditSettingsCommand } from './audit-settings.js';
|
|
31
|
-
import { discoverSkillsFromConfig } from './skills/skill-discovery.js';
|
|
35
|
+
import { discoverSkillsFromConfig, SKILLS_INCLUDE_REMEDY, } from './skills/skill-discovery.js';
|
|
32
36
|
/** Resource type constant for agent skills, avoiding duplicate string literals. */
|
|
33
37
|
const RESOURCE_TYPE_AGENT_SKILL = 'agent-skill';
|
|
34
38
|
/** Resource type constant for Claude plugins, avoiding duplicate string literals. */
|
|
35
39
|
const RESOURCE_TYPE_CLAUDE_PLUGIN = 'claude-plugin';
|
|
36
|
-
const VAT_CONFIG_FILENAME = 'vibe-agent-toolkit.config.yaml';
|
|
37
40
|
/**
|
|
38
41
|
* Build config-aware context for a single VAT project at `scanRoot`.
|
|
39
42
|
*
|
|
@@ -50,9 +53,8 @@ const VAT_CONFIG_FILENAME = 'vibe-agent-toolkit.config.yaml';
|
|
|
50
53
|
*
|
|
51
54
|
* Returns `null` if `scanRoot` has no config or the config has no skills.
|
|
52
55
|
*/
|
|
53
|
-
async function buildVATProjectContext(scanRoot, logger) {
|
|
56
|
+
async function buildVATProjectContext(scanRoot, logger, locationRoot) {
|
|
54
57
|
const configPath = safePath.join(scanRoot, VAT_CONFIG_FILENAME);
|
|
55
|
-
// eslint-disable-next-line security/detect-non-literal-fs-filename -- scanRoot is a controlled parameter
|
|
56
58
|
if (!fs.existsSync(configPath)) {
|
|
57
59
|
return null;
|
|
58
60
|
}
|
|
@@ -68,7 +70,7 @@ async function buildVATProjectContext(scanRoot, logger) {
|
|
|
68
70
|
return null;
|
|
69
71
|
const skillConfigs = new Map();
|
|
70
72
|
try {
|
|
71
|
-
const discovered = await discoverSkillsFromConfig(config.skills, scanRoot);
|
|
73
|
+
const discovered = await discoverSkillsFromConfig(config.skills, scanRoot, degradingDiscovery(scanRoot, logger, locationRoot));
|
|
72
74
|
const { defaults, config: perSkillConfig } = config.skills;
|
|
73
75
|
for (const skill of discovered) {
|
|
74
76
|
const absPath = safePath.resolve(skill.sourcePath);
|
|
@@ -78,7 +80,16 @@ async function buildVATProjectContext(scanRoot, logger) {
|
|
|
78
80
|
logger.debug(`Config-aware audit: found ${discovered.length} skill(s) in ${scanRoot}`);
|
|
79
81
|
}
|
|
80
82
|
catch (error) {
|
|
81
|
-
|
|
83
|
+
// Discovery was handed the degrade policy above, so a refused listing never
|
|
84
|
+
// reaches here; one that does (a caller that switched it to `'refuse'`) is
|
|
85
|
+
// still degraded past through the same ledger rather than swallowed. It used
|
|
86
|
+
// to be `logger.debug` for ANY error, which is how a `chmod 000` directory
|
|
87
|
+
// under `skills.include` downgraded every skill to config-free validation with
|
|
88
|
+
// the directory named nowhere an operator without `--debug` could see it. A
|
|
89
|
+
// defect in discovery is not environmental and still fails loudly.
|
|
90
|
+
if (!(error instanceof DirectoryListingRefusedError))
|
|
91
|
+
throw error;
|
|
92
|
+
recordRefusedDiscovery(error, scanRoot, logger, locationRoot);
|
|
82
93
|
}
|
|
83
94
|
if (skillConfigs.size === 0) {
|
|
84
95
|
return null;
|
|
@@ -161,22 +172,287 @@ function packagingResultToValidationResult(skillPath, result, configTargets, loc
|
|
|
161
172
|
* depth, so crawling again reports one file twice, while omitting it for a
|
|
162
173
|
* standalone bundle is the blindness this parameter exists to end.
|
|
163
174
|
*/
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
175
|
+
/**
|
|
176
|
+
* Resolve a skill's nearest-ancestor `vibe-agent-toolkit.config.yaml`, treating
|
|
177
|
+
* one that cannot be LOADED as absent rather than as fatal.
|
|
178
|
+
*
|
|
179
|
+
* 🔑 Both audit lanes call this, and that is the point. They used to carry
|
|
180
|
+
* separate copies of the fork, and the copies disagreed: the single-target lane
|
|
181
|
+
* caught `ConfigLoadError` and fell back, the directory-scan lane let it throw.
|
|
182
|
+
* One unrecognized key in one nested config therefore aborted a whole-tree scan
|
|
183
|
+
* with exit 2 and zero skills audited, while auditing a single SKILL.md under
|
|
184
|
+
* that same config exited 0 — a verdict that depended on whether the argument
|
|
185
|
+
* was a file or its parent directory. Keeping the tolerance in ONE function is
|
|
186
|
+
* what stops the two answers drifting apart again.
|
|
187
|
+
*
|
|
188
|
+
* Degrading beats destroying, and silence is not the alternative — the policy
|
|
189
|
+
* `SCAN_PATH_UNREADABLE` states in its own registry docstring. So this warns
|
|
190
|
+
* rather than logging at debug, AND files a finding on the config path, because
|
|
191
|
+
* the warning goes to stderr while the report goes to stdout: a run that
|
|
192
|
+
* downgraded N skills to the much weaker config-free `validateSkill` and exited 0
|
|
193
|
+
* said nothing about it in the machine-readable artifact anyone actually diffs.
|
|
194
|
+
* That is the "detector silently disables itself" shape this code cites and was
|
|
195
|
+
* still doing.
|
|
196
|
+
*
|
|
197
|
+
* ⚠️ The filesystem question IS asked, and the answer changes what the operator
|
|
198
|
+
* is told. `config-loader.ts` preserves `cause` specifically so callers can ask
|
|
199
|
+
* `isFilesystemAccessError` and "decide whether to degrade or abort"; catching
|
|
200
|
+
* `ConfigLoadError` unconditionally never asked at all, so a `chmod 000` config
|
|
201
|
+
* and a typo produced the same sentence — and a permissions problem is not a
|
|
202
|
+
* broken config.
|
|
203
|
+
*
|
|
204
|
+
* 🔑 Asking it and still DEGRADING is deliberate, and was measured. Re-throwing
|
|
205
|
+
* so the per-entry guard anchors the finding is the obvious alternative and is
|
|
206
|
+
* worse: on a fixture with one unreadable config and one perfectly readable
|
|
207
|
+
* skill, the re-throw produced `filesScanned: 1, filesPassed: 0` with the finding
|
|
208
|
+
* anchored on `skills/demo/SKILL.md` and the skill never validated at all. On a
|
|
209
|
+
* real `--user` run that is hundreds of readable skills losing every check
|
|
210
|
+
* because one config is `chmod 000` — the exact "destroying" this lane exists to
|
|
211
|
+
* prevent, and a violation of the neighbouring promise that "a skill beside the
|
|
212
|
+
* unreadable path is validated exactly as if it were not there". The finding is
|
|
213
|
+
* filed on the CONFIG, which is the path that was actually refused, and the skill
|
|
214
|
+
* goes on to config-free validation.
|
|
215
|
+
*
|
|
216
|
+
* @returns the skill's display-safe packaging config, or `null` when there is
|
|
217
|
+
* no governing config OR when the one found could not be loaded.
|
|
218
|
+
*/
|
|
219
|
+
async function resolveGoverningConfig(skillPath, logger, locationRoot) {
|
|
220
|
+
// The walk-up the resolver is about to do (cached), so the degrade handler can
|
|
221
|
+
// name the config whose include reached the directory. `null` means no config
|
|
222
|
+
// governs this skill; the resolver answers the same and nothing can be refused.
|
|
223
|
+
const configRoot = findProjectRoot(safePath.resolve(skillPath, '..'));
|
|
224
|
+
if (configRoot === null)
|
|
225
|
+
return null;
|
|
169
226
|
let fullConfig;
|
|
170
227
|
try {
|
|
171
|
-
fullConfig = await resolveSkillPackagingConfig(skillPath);
|
|
228
|
+
fullConfig = await resolveSkillPackagingConfig(skillPath, degradingDiscovery(configRoot, logger, locationRoot));
|
|
172
229
|
}
|
|
173
230
|
catch (err) {
|
|
174
|
-
if (
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
231
|
+
if (err instanceof ConfigLoadError) {
|
|
232
|
+
recordUnloadableConfig(err, logger, locationRoot);
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
// Unreachable while the resolver is handed `degradingDiscovery` above — the
|
|
236
|
+
// crawl then enumerates around a refused directory instead of throwing — but
|
|
237
|
+
// kept as the fallback for the default policy: same two channels, the skill
|
|
238
|
+
// goes on config-free rather than the run aborting.
|
|
239
|
+
if (err instanceof DirectoryListingRefusedError) {
|
|
240
|
+
recordRefusedDiscovery(err, configRoot, logger, locationRoot);
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
throw err;
|
|
178
244
|
}
|
|
179
|
-
|
|
245
|
+
return fullConfig === null ? null : stripValidationAllowForDisplay(fullConfig);
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Configs this run has already degraded past — unloadable, or loaded but with a
|
|
249
|
+
* `skills.include` the crawl could not fully expand — keyed by the PATH that was
|
|
250
|
+
* refused (the config file, or the directory the crawl could not list), each
|
|
251
|
+
* holding the synthetic result that carries its finding into the report.
|
|
252
|
+
*
|
|
253
|
+
* 🚨 MODULE-scoped, and cleared by {@link resetAuditCaches}, exactly like
|
|
254
|
+
* `config-loader.ts`'s `warnedConfigPaths`. It used to be a `Set` threaded as a
|
|
255
|
+
* parameter, and the threading had a hole: `handleFileEntry` passed the scan's
|
|
256
|
+
* real log, but `validateSingleSkill` constructed a fresh `new Set()` on EVERY
|
|
257
|
+
* call — and `validatePluginSkillsViaInventory` calls it in a loop over every
|
|
258
|
+
* skill in a plugin. A 40-skill plugin under one unloadable config printed 40
|
|
259
|
+
* identical warnings. The integration fixture was a bare directory, which routes
|
|
260
|
+
* through `handleFileEntry`, so the "warns once, not once per skill" test passed
|
|
261
|
+
* while the lane that actually repeated was never exercised. A run-scoped ledger
|
|
262
|
+
* cannot be re-created per call by mistake; a parameter can.
|
|
263
|
+
*
|
|
264
|
+
* ⚠️ Keyed on the refused PATH (derived from `ConfigLoadError.projectRoot` or
|
|
265
|
+
* `DirectoryListingRefusedError.refusal.directory`), NOT on `err.message`. The
|
|
266
|
+
* old key was the message with a comment claiming it "names the offending config
|
|
267
|
+
* path" — true only for a Zod failure, which is what the fixture happened to be.
|
|
268
|
+
* A YAML SYNTAX error is thrown out of `yaml.parse` and wrapped as `Failed to
|
|
269
|
+
* load config: ${message}`, and `YAMLParseError.message` carries no filename at
|
|
270
|
+
* all, so two nested projects with the same copy-pasted broken config collapsed
|
|
271
|
+
* to ONE warning naming NEITHER.
|
|
272
|
+
*/
|
|
273
|
+
const unloadableConfigResults = new Map();
|
|
274
|
+
/**
|
|
275
|
+
* The RUN's conventional-suite probe — the memo behind "does
|
|
276
|
+
* `<skill-root>/evals/evals.json` exist?".
|
|
277
|
+
*
|
|
278
|
+
* Resolving one skill's test input asks that question of the SUBJECT and of
|
|
279
|
+
* every entry in `projectSkills`, so a probe minted per skill costs S questions
|
|
280
|
+
* per skill and S² per run over the same S paths. Measured with the lab on a
|
|
281
|
+
* 103-skill adopter: 10,815 probes over 103 distinct paths, half of the
|
|
282
|
+
* command's entire filesystem traffic.
|
|
283
|
+
*
|
|
284
|
+
* 🚨 MODULE-scoped and cleared by {@link resetAuditCaches}, for the same reason
|
|
285
|
+
* `unloadableConfigResults` above is: this command has TWO lanes that build a
|
|
286
|
+
* shared context — {@link validateSingleSkill} and {@link handleFileEntry} — and
|
|
287
|
+
* both build it PER SKILL, inside their own loops. A probe threaded as a
|
|
288
|
+
* parameter would be re-minted by whichever lane forgot to thread it, silently,
|
|
289
|
+
* and that is exactly how this stayed quadratic: the field existed and no caller
|
|
290
|
+
* set it.
|
|
291
|
+
*
|
|
292
|
+
* ⚠️ Its lifetime is the RUN, never longer. The answer is a filesystem snapshot,
|
|
293
|
+
* and a memo outliving the invocation would keep answering for a tree that has
|
|
294
|
+
* since changed — in the CLI entrypoint AND in an in-process test sharing a
|
|
295
|
+
* vitest worker.
|
|
296
|
+
*/
|
|
297
|
+
let runSuiteProbe;
|
|
298
|
+
/** The run's probe, minted on first use and dropped by {@link resetAuditCaches}. */
|
|
299
|
+
function suiteProbeForRun() {
|
|
300
|
+
runSuiteProbe ??= conventionalSuiteProbe();
|
|
301
|
+
return runSuiteProbe;
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Warn about — and file a finding for — a governing config this run could not
|
|
305
|
+
* load, once per config however many skills it governs.
|
|
306
|
+
*
|
|
307
|
+
* @param err - The load failure, carrying the project root that owns the config
|
|
308
|
+
* @param logger - Warnings go to stderr; the finding goes to the report
|
|
309
|
+
* @param locationRoot - The run's anchor base for the emitted location
|
|
310
|
+
*/
|
|
311
|
+
function recordUnloadableConfig(err, logger, locationRoot) {
|
|
312
|
+
const configPath = safePath.join(err.projectRoot, VAT_CONFIG_FILENAME);
|
|
313
|
+
if (unloadableConfigResults.has(configPath))
|
|
314
|
+
return;
|
|
315
|
+
unloadableConfigResults.set(configPath, unreadablePathResult(configPath, err, locationRoot));
|
|
316
|
+
// The filesystem answer changes the NOUN, because the two conditions need
|
|
317
|
+
// different actions from the operator: `chmod`/ownership on one side, an edit
|
|
318
|
+
// to the file on the other. Collapsing them into one sentence was the whole
|
|
319
|
+
// cost of never asking.
|
|
320
|
+
const why = isFilesystemAccessError(err) ? 'unreadable' : 'unloadable';
|
|
321
|
+
logger.warn(`Ignoring ${why} config at ${configPath}; skills it governs are validated`
|
|
322
|
+
+ ` config-free: ${err.message}`);
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Audit's discovery policy: enumerate AROUND a directory the crawl cannot list,
|
|
326
|
+
* report the gap, and keep every skill the crawl could see under its config.
|
|
327
|
+
*
|
|
328
|
+
* 🔑 This is what keeps ONE `chmod 000` sibling from costing a readable,
|
|
329
|
+
* declared skill every config-aware check. Discovery's default is to refuse —
|
|
330
|
+
* right for a build that must not ship a shorter bundle — but audit already
|
|
331
|
+
* validates "a skill beside the unreadable path exactly as if it were not
|
|
332
|
+
* there", and that promise is only kept if discovery degrades the same way the
|
|
333
|
+
* walk does. The refusal is recorded through the same ledger as an unloadable
|
|
334
|
+
* config, so it is warned about once and filed once however many skills ask.
|
|
335
|
+
*
|
|
336
|
+
* @param configRoot - The project root whose `skills.include` is being expanded
|
|
337
|
+
* @param logger - Warnings go to stderr; the finding goes to the report
|
|
338
|
+
* @param locationRoot - The run's anchor base for the emitted location
|
|
339
|
+
*/
|
|
340
|
+
function degradingDiscovery(configRoot, logger, locationRoot) {
|
|
341
|
+
return {
|
|
342
|
+
degrade: (refusal) => {
|
|
343
|
+
recordRefusedDiscovery(new DirectoryListingRefusedError(refusal, { root: configRoot, remedy: SKILLS_INCLUDE_REMEDY }), configRoot, logger, locationRoot);
|
|
344
|
+
},
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Audit's ruling for the registry crawl — the link graph `crawlAndResolveRegistry`
|
|
349
|
+
* builds over the whole project root for the config-aware lane: enumerate
|
|
350
|
+
* AROUND a directory it cannot list, file the gap, keep every readable document.
|
|
351
|
+
*
|
|
352
|
+
* The other half of {@link degradingDiscovery}, on the other crawl the
|
|
353
|
+
* config-aware lane makes. Every other verb hands this crawl `'refuse'` (a build
|
|
354
|
+
* must not ship a shorter bundle), and while the registry carried that ruling
|
|
355
|
+
* INSIDE itself `vat audit` on a tree with one `chmod 000` sibling exited 2
|
|
356
|
+
* with `status: error` and ZERO findings — the unreadable-directory defect's
|
|
357
|
+
* exact shape, from the crawl its first fix never looked at. Audit's contract is the opposite:
|
|
358
|
+
* status describes what was found, exit describes whether the run completed.
|
|
359
|
+
*
|
|
360
|
+
* Recorded through the same per-directory ledger as a refused discovery, so a
|
|
361
|
+
* directory both crawls meet is warned about once and filed once; a directory
|
|
362
|
+
* only THIS crawl meets (the link graph covers the whole root, discovery only
|
|
363
|
+
* `skills.include`'s bases) gets its own sentence, naming the crawl that
|
|
364
|
+
* skipped it. The finding carries the projection's remedy — the same sentence
|
|
365
|
+
* the refuse ruling throws — because `--exclude` governs audit's own walk, not
|
|
366
|
+
* this crawl, and an ignore rule is the one knob that does.
|
|
367
|
+
*
|
|
368
|
+
* @param projectRoot - The root the link graph is crawled from
|
|
369
|
+
* @param logger - Warnings go to stderr; the finding goes to the report
|
|
370
|
+
* @param locationRoot - The run's anchor base for the emitted location
|
|
371
|
+
*/
|
|
372
|
+
function degradingRegistry(projectRoot, logger, locationRoot) {
|
|
373
|
+
return {
|
|
374
|
+
degrade: (refusal) => {
|
|
375
|
+
const err = new DirectoryListingRefusedError(refusal, { root: projectRoot, remedy: listingRefusalRemedy(projectRoot) });
|
|
376
|
+
recordRefusedListing(err, logger, locationRoot, () => `The link graph under ${projectRoot} skipped an unreadable directory; every document beneath it`
|
|
377
|
+
+ ` is missing from this audit's link and reference checks: ${err.message}`);
|
|
378
|
+
},
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Warn about — and file a finding for — a directory that skill discovery under
|
|
383
|
+
* `configRoot` could not list, once per directory however many skills ask.
|
|
384
|
+
*
|
|
385
|
+
* The finding is anchored on the DIRECTORY, not the config: the config loaded
|
|
386
|
+
* fine, and the directory is the path the filesystem refused. In the directory
|
|
387
|
+
* lane the walk's own per-entry guard files the same code on the same location;
|
|
388
|
+
* {@link dedupeUnreadablePathResults} keeps one. The warning carries the crawl's
|
|
389
|
+
* own sentence, which already names the directory against the project root and
|
|
390
|
+
* the `skills.include` remedy.
|
|
391
|
+
*
|
|
392
|
+
* @param err - The refusal, naming the directory the crawl could not list
|
|
393
|
+
* @param configRoot - The project root whose `skills.include` reached it
|
|
394
|
+
* @param logger - Warnings go to stderr; the finding goes to the report
|
|
395
|
+
* @param locationRoot - The run's anchor base for the emitted location
|
|
396
|
+
*/
|
|
397
|
+
function recordRefusedDiscovery(err, configRoot, logger, locationRoot) {
|
|
398
|
+
const configPath = safePath.join(configRoot, VAT_CONFIG_FILENAME);
|
|
399
|
+
recordRefusedListing(err, logger, locationRoot, () => `Skill discovery under ${configPath} skipped an unreadable directory; any skill beneath it`
|
|
400
|
+
+ ` is missing from this audit: ${err.message}`);
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* The one ledger write for a refused listing, whichever crawl met it: file
|
|
404
|
+
* `SCAN_PATH_UNREADABLE` on the directory and warn once on stderr. Keyed on the
|
|
405
|
+
* refused directory, so the second crawl to meet it — discovery and the link
|
|
406
|
+
* graph both walk `skills/locked` in the directory lane — says nothing more.
|
|
407
|
+
*
|
|
408
|
+
* @param err - The refusal, naming the directory the crawl could not list
|
|
409
|
+
* @param logger - Warnings go to stderr; the finding goes to the report
|
|
410
|
+
* @param locationRoot - The run's anchor base for the emitted location
|
|
411
|
+
* @param warning - The stderr sentence, built only when this is the first record
|
|
412
|
+
*/
|
|
413
|
+
function recordRefusedListing(err, logger, locationRoot, warning) {
|
|
414
|
+
const directory = err.refusal.directory;
|
|
415
|
+
if (unloadableConfigResults.has(directory))
|
|
416
|
+
return;
|
|
417
|
+
unloadableConfigResults.set(directory, unreadablePathResult(directory, err, locationRoot));
|
|
418
|
+
logger.warn(warning());
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* The synthetic results for every config this run degraded past, so the report
|
|
422
|
+
* carries what the stderr warning says.
|
|
423
|
+
*
|
|
424
|
+
* Read, never drained: `resetAuditCaches()` owns the lifetime, and a drain would
|
|
425
|
+
* make the answer depend on which aggregation site ran first.
|
|
426
|
+
*/
|
|
427
|
+
function unloadableConfigFindings() {
|
|
428
|
+
return [...unloadableConfigResults.values()];
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* A run's results plus the findings for every config it degraded past, with each
|
|
432
|
+
* refused path reported once.
|
|
433
|
+
*
|
|
434
|
+
* Appended, not merged into a neighbour: a config the scan could not load, or a
|
|
435
|
+
* directory its discovery could not list, has no host result to hang off, and
|
|
436
|
+
* attributing it to a skill would name a file that was read fine. Without the
|
|
437
|
+
* append the ONLY record was a stderr warning, so a run that downgraded every
|
|
438
|
+
* skill under that config to the weaker config-free validator exited 0 with a
|
|
439
|
+
* report that said nothing about it. Deduped AFTER the append, because the
|
|
440
|
+
* directory lane's walk files its own `SCAN_PATH_UNREADABLE` on the very
|
|
441
|
+
* directory a refused discovery names — one fact, one finding, whichever lane
|
|
442
|
+
* met it first.
|
|
443
|
+
*/
|
|
444
|
+
function withDegradedConfigFindings(results) {
|
|
445
|
+
return dedupeUnreadablePathResults([...results, ...unloadableConfigFindings()]);
|
|
446
|
+
}
|
|
447
|
+
async function validateSingleSkill(skillPath, options, logger, locationRoot, crawlTree, isVATGenerated) {
|
|
448
|
+
// Config-aware validation: walk UP to the skill's nearest-ancestor
|
|
449
|
+
// vibe-agent-toolkit.config.yaml and apply the skill's packaging block. A
|
|
450
|
+
// config that cannot be loaded is treated as absent — see
|
|
451
|
+
// {@link resolveGoverningConfig}, which both audit lanes share so they cannot
|
|
452
|
+
// disagree about that again. The warn-once ledger is run-scoped module state,
|
|
453
|
+
// NOT a set minted here — a fresh `new Set()` on this line is what made a
|
|
454
|
+
// 40-skill plugin print 40 identical warnings.
|
|
455
|
+
const skillConfig = await resolveGoverningConfig(skillPath, logger, locationRoot);
|
|
180
456
|
if (skillConfig !== null) {
|
|
181
457
|
logger.debug(` Using config-aware validation for: ${skillPath}`);
|
|
182
458
|
const { gitTracker } = await resolveScanContext(safePath.resolve(skillPath), locationRoot, logger);
|
|
@@ -188,10 +464,18 @@ async function validateSingleSkill(skillPath, options, logger, locationRoot, cra
|
|
|
188
464
|
// The project's declared eval suites, memoized per config root, so this lane
|
|
189
465
|
// predicts the same bundle the packager produces: no skill's report counts a
|
|
190
466
|
// SIBLING skill's eval suite as an ordinary bundled file.
|
|
467
|
+
// Audit's ruling for the link-graph crawl — see {@link degradingRegistry}.
|
|
468
|
+
// Stated on the context too, for the fallback crawl `validateSkillForPackaging`
|
|
469
|
+
// makes when this registry does not cover the skill: one ruling, both crawls.
|
|
470
|
+
const unreadable = degradingRegistry(projectRoot, logger, locationRoot);
|
|
191
471
|
const sharedCtx = {
|
|
192
|
-
registry: await crawlAndResolveRegistry(projectRoot),
|
|
472
|
+
registry: await crawlAndResolveRegistry(projectRoot, { unreadable }),
|
|
473
|
+
unreadable,
|
|
193
474
|
locationRoot,
|
|
194
|
-
projectSkills: await resolveProjectDeclaredEvalSuites(skillPath),
|
|
475
|
+
projectSkills: await resolveProjectDeclaredEvalSuites(skillPath, degradingDiscovery(projectRoot, logger, locationRoot)),
|
|
476
|
+
// The RUN's probe, not this call's — see {@link runSuiteProbe}. This lane
|
|
477
|
+
// is called in a loop over every skill in a plugin.
|
|
478
|
+
suiteProbe: suiteProbeForRun(),
|
|
195
479
|
};
|
|
196
480
|
if (gitTracker !== null) {
|
|
197
481
|
sharedCtx.gitTracker = gitTracker;
|
|
@@ -201,8 +485,8 @@ async function validateSingleSkill(skillPath, options, logger, locationRoot, cra
|
|
|
201
485
|
await appendDistributedTreeFindings(configAware, skillPath, locationRoot, crawlTree);
|
|
202
486
|
return configAware;
|
|
203
487
|
}
|
|
204
|
-
// Fallback: basic validation
|
|
205
|
-
const validateOptions = { skillPath, locationRoot };
|
|
488
|
+
// Fallback: basic validation. Severity is applied by `applySeverityFilter`.
|
|
489
|
+
const validateOptions = { skillPath, locationRoot, validation: {} };
|
|
206
490
|
if (options.warnUnreferencedFiles) {
|
|
207
491
|
validateOptions.checkUnreferencedFiles = true;
|
|
208
492
|
}
|
|
@@ -248,6 +532,14 @@ function collect(value, previous) {
|
|
|
248
532
|
/**
|
|
249
533
|
* Create audit command
|
|
250
534
|
* Top-level command: vat audit [path]
|
|
535
|
+
*
|
|
536
|
+
* The exit code follows the document's `status`, under the one exit-code
|
|
537
|
+
* contract every verb shares: `OK` when nothing is at error severity,
|
|
538
|
+
* `FINDINGS` when something is (or the run audited zero files), `ERROR` when
|
|
539
|
+
* the audit could not run at all. This command used to be the one exception —
|
|
540
|
+
* "advisory", exit 0 over `status: error` — which a CI author reading the
|
|
541
|
+
* shared contract got wrong on exactly this verb. Warnings never move the
|
|
542
|
+
* exit code; `validation.severity` is the dial that decides what counts.
|
|
251
543
|
*/
|
|
252
544
|
export function createAuditCommand() {
|
|
253
545
|
const audit = new Command('audit');
|
|
@@ -314,11 +606,20 @@ Description:
|
|
|
314
606
|
done
|
|
315
607
|
(See packages/cli/docs/audit.md for details.)
|
|
316
608
|
|
|
609
|
+
Output:
|
|
610
|
+
The YAML 'status' describes the findings, and the exit code follows it, as
|
|
611
|
+
in every other command of this CLI.
|
|
612
|
+
|
|
613
|
+
A run that audited ZERO files is refused, not passed: 'status: error' with one
|
|
614
|
+
non-overridable RESOURCE_CHECK_BROKEN under a top-level 'issues:' (the claim is
|
|
615
|
+
about the run, so it is not a files[] row), and exit 1.
|
|
616
|
+
|
|
317
617
|
Validation Behavior:
|
|
318
|
-
|
|
618
|
+
Audit surfaces all validation issues for inspection.
|
|
319
619
|
Unlike 'vat skills validate', audit:
|
|
320
620
|
- NEVER applies validation.allow (allowed codes are always shown)
|
|
321
|
-
- Respects validation.severity:
|
|
621
|
+
- Respects validation.severity: a code set to 'ignore' is hidden; warnings
|
|
622
|
+
and errors are both reported, each at its configured severity
|
|
322
623
|
- ALWAYS exits 0 for validation results (never gates on errors)
|
|
323
624
|
|
|
324
625
|
For gated validation (CI/CD), use: vat skills validate
|
|
@@ -338,11 +639,16 @@ Validation Checks:
|
|
|
338
639
|
|
|
339
640
|
Warnings (should fix):
|
|
340
641
|
- Skill exceeds recommended length (>5000 lines)
|
|
341
|
-
-
|
|
342
|
-
(
|
|
343
|
-
COMPAT_REQUIRES_EXTERNAL_CLI). See docs/validation-codes.md.
|
|
642
|
+
- With --compat: a declared target cannot or may not run the skill
|
|
643
|
+
(COMPAT_TARGET_INCOMPATIBLE, COMPAT_TARGET_NEEDS_REVIEW)
|
|
344
644
|
- Unreferenced files detected (with --warn-unreferenced-files)
|
|
345
645
|
|
|
646
|
+
Info (observations):
|
|
647
|
+
- Capability observations — browser auth, local shell, or external CLI
|
|
648
|
+
(CAPABILITY_BROWSER_AUTH, CAPABILITY_LOCAL_SHELL, CAPABILITY_EXTERNAL_CLI);
|
|
649
|
+
with --compat, a skill declaring no targets (COMPAT_TARGET_UNDECLARED).
|
|
650
|
+
See docs/validation-codes.md.
|
|
651
|
+
|
|
346
652
|
Gitignore-Aware Scanning:
|
|
347
653
|
When scanning inside a git repository, paths matched by .gitignore are
|
|
348
654
|
skipped by default. This excludes build artifacts (dist/), dependencies
|
|
@@ -352,8 +658,9 @@ Gitignore-Aware Scanning:
|
|
|
352
658
|
Use --include-artifacts to scan gitignored paths (e.g., to audit a
|
|
353
659
|
bundled marketplace plugin in dist/).
|
|
354
660
|
|
|
355
|
-
User-supplied --exclude patterns are always applied on top.
|
|
356
|
-
|
|
661
|
+
User-supplied --exclude patterns are always applied on top. The
|
|
662
|
+
directories no VAT crawl enters (node_modules/, .git/, coverage/,
|
|
663
|
+
.turbo/, git worktrees) stay out under every flag, in or out of git.
|
|
357
664
|
|
|
358
665
|
Config-Aware Validation:
|
|
359
666
|
The governing vibe-agent-toolkit.config.yaml is found by walking UP from
|
|
@@ -363,14 +670,29 @@ Config-Aware Validation:
|
|
|
363
670
|
which prevents false warnings for links the build pipeline resolves.
|
|
364
671
|
|
|
365
672
|
Config-aware mode never applies validation.allow — audit always shows
|
|
366
|
-
all issues. validation.severity IS applied
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
673
|
+
all issues. validation.severity IS applied, from three scopes, least
|
|
674
|
+
specific first:
|
|
675
|
+
resources.validation.severity project-wide (the same dial
|
|
676
|
+
'vat resources validate' reads)
|
|
677
|
+
skills.defaults.validation.severity project-wide (skills, plugins and
|
|
678
|
+
marketplaces alike)
|
|
679
|
+
skills.config.<name>.validation... that one skill
|
|
370
680
|
|
|
371
681
|
Exit Codes:
|
|
372
|
-
0 -
|
|
373
|
-
|
|
682
|
+
0 - The audit completed with nothing at error severity (warnings and
|
|
683
|
+
informational findings are in the report, not the exit code).
|
|
684
|
+
1 - The audit completed and reports 'status: error': at least one
|
|
685
|
+
error-severity finding, or zero files audited. A path inside the tree
|
|
686
|
+
the scan could not read, or a governing config that cannot be loaded
|
|
687
|
+
or whose skills.include reaches an unreadable directory, is
|
|
688
|
+
SCAN_PATH_UNREADABLE (warning): the run is degraded, not failed —
|
|
689
|
+
readable siblings are still validated, the config's skills
|
|
690
|
+
config-free, and the refused path is not counted in filesScanned.
|
|
691
|
+
2 - The audit could not run at all: the path does not exist, is a
|
|
692
|
+
directory the OS will not list, or is a file no audit lane
|
|
693
|
+
recognises, --user with no Claude config dir, a git URL
|
|
694
|
+
that would not clone, an unknown flag, an internal failure. There is
|
|
695
|
+
no report to read.
|
|
374
696
|
|
|
375
697
|
Examples:
|
|
376
698
|
$ vat audit ./plugins/ # Audit recursively (default)
|
|
@@ -424,11 +746,8 @@ async function auditUserDirectories(recursive, options, startTime, logger) {
|
|
|
424
746
|
// --user scans three sibling directories; they share ONE enclosing Claude
|
|
425
747
|
// config dir, so that is the run's single stated root.
|
|
426
748
|
const scanRoot = safePath.resolve(claudeDir);
|
|
427
|
-
// eslint-disable-next-line security/detect-non-literal-fs-filename -- safe: path constructed from os.homedir()
|
|
428
749
|
const pluginsDirExists = fs.existsSync(pluginsDir);
|
|
429
|
-
// eslint-disable-next-line security/detect-non-literal-fs-filename -- safe: path constructed from os.homedir()
|
|
430
750
|
const skillsDirExists = fs.existsSync(skillsDir);
|
|
431
|
-
// eslint-disable-next-line security/detect-non-literal-fs-filename -- safe: path constructed from os.homedir()
|
|
432
751
|
const marketplacesDirExists = fs.existsSync(marketplacesDir);
|
|
433
752
|
if (!pluginsDirExists && !skillsDirExists && !marketplacesDirExists) {
|
|
434
753
|
logger.error(`No user-level Claude directories found:`);
|
|
@@ -436,9 +755,9 @@ async function auditUserDirectories(recursive, options, startTime, logger) {
|
|
|
436
755
|
logger.error(` Skills: ${skillsDir}`);
|
|
437
756
|
logger.error(` Marketplaces: ${marketplacesDir}`);
|
|
438
757
|
logger.error('Claude plugins/skills/marketplaces have not been installed yet.');
|
|
439
|
-
process.exit(
|
|
758
|
+
process.exit(ExitCode.ERROR);
|
|
440
759
|
}
|
|
441
|
-
const
|
|
760
|
+
const scanned = [];
|
|
442
761
|
// The roots that exist, minus any one another already covers — see
|
|
443
762
|
// {@link userScanTargets}. `marketplaces/` lives inside `plugins/`.
|
|
444
763
|
const present = [
|
|
@@ -448,8 +767,12 @@ async function auditUserDirectories(recursive, options, startTime, logger) {
|
|
|
448
767
|
];
|
|
449
768
|
for (const target of userScanTargets(present, recursive)) {
|
|
450
769
|
logger.debug(`Auditing user-level resources at: ${target}`);
|
|
451
|
-
|
|
770
|
+
scanned.push(...await getValidationResults(target, recursive, options, logger, scanRoot));
|
|
452
771
|
}
|
|
772
|
+
// The `--user` report is assembled here rather than by `buildAuditReport`, so
|
|
773
|
+
// it needs its own append or the config findings would reach stderr and not the
|
|
774
|
+
// document — see the same call in `buildAuditReport`.
|
|
775
|
+
const results = withDegradedConfigFindings(scanned);
|
|
453
776
|
// Run compatibility analysis if --compat flag is set
|
|
454
777
|
const compatMap = options.compat
|
|
455
778
|
? await runCompatAnalysis(results, logger, scanRoot) // --settings not supported in --user mode
|
|
@@ -462,7 +785,7 @@ async function auditUserDirectories(recursive, options, startTime, logger) {
|
|
|
462
785
|
if (verbose) {
|
|
463
786
|
renderVerboseEvidence(results, scanRoot, logger);
|
|
464
787
|
}
|
|
465
|
-
logHierarchicalSummary(results, logger);
|
|
788
|
+
logHierarchicalSummary(results, summary.issues, logger);
|
|
466
789
|
}
|
|
467
790
|
/** Skill resource types that can have per-skill validation config. */
|
|
468
791
|
const SKILL_RESULT_TYPES = new Set([RESOURCE_TYPE_AGENT_SKILL, 'vat-agent']);
|
|
@@ -486,7 +809,6 @@ const SKILL_RESULT_TYPES = new Set([RESOURCE_TYPE_AGENT_SKILL, 'vat-agent']);
|
|
|
486
809
|
*/
|
|
487
810
|
export function deriveScanRoot(targetPath) {
|
|
488
811
|
const resolved = safePath.resolve(targetPath);
|
|
489
|
-
// eslint-disable-next-line security/detect-non-literal-fs-filename -- resolved is the operator's own audit target
|
|
490
812
|
const isDirectory = fs.existsSync(resolved) && fs.statSync(resolved).isDirectory();
|
|
491
813
|
return isDirectory ? resolved : safePath.resolve(resolved, '..');
|
|
492
814
|
}
|
|
@@ -534,7 +856,7 @@ function buildFilteredResult(result, filteredIssues) {
|
|
|
534
856
|
/**
|
|
535
857
|
* Apply severity resolution to validation results.
|
|
536
858
|
*
|
|
537
|
-
* Audit
|
|
859
|
+
* Audit shows everything: it applies `validation.severity` to decide what to
|
|
538
860
|
* show and at what severity, but deliberately ignores `validation.allow`.
|
|
539
861
|
*
|
|
540
862
|
* **Both directions, via the one shared resolver.** This used to compute the
|
|
@@ -546,13 +868,21 @@ function buildFilteredResult(result, filteredIssues) {
|
|
|
546
868
|
* same config promoted correctly. Two lanes, one config key, opposite answers.
|
|
547
869
|
*
|
|
548
870
|
* Status and counts are re-derived from the resolved severities by
|
|
549
|
-
* {@link buildFilteredResult}, so a promotion moves the reported
|
|
550
|
-
*
|
|
551
|
-
*
|
|
552
|
-
*
|
|
553
|
-
*
|
|
554
|
-
*
|
|
555
|
-
*
|
|
871
|
+
* {@link buildFilteredResult}, so a promotion moves the reported STATUS — which
|
|
872
|
+
* describes the findings — and never the exit code, which describes whether the
|
|
873
|
+
* run completed. The two are different questions and audit publishes both; see
|
|
874
|
+
* {@link handleAuditResults} and the `Output` section of this command's help.
|
|
875
|
+
* `vat verify` derives its exit code from severity because it is a gate; this
|
|
876
|
+
* command is a report.
|
|
877
|
+
*
|
|
878
|
+
* THREE scopes, least specific first:
|
|
879
|
+
* - `resources.validation.severity` — the PROJECT-WIDE map `vat resources
|
|
880
|
+
* validate` already honours. Audit read only the two `skills.*` scopes below,
|
|
881
|
+
* so one dial in one config file produced opposite answers from the two
|
|
882
|
+
* commands: measured on a two-code fixture, `LINK_MISSING_TARGET: ignore` and
|
|
883
|
+
* `LINK_TO_NAVIGATION_FILE: error` written here moved neither finding in
|
|
884
|
+
* audit. It is applied FIRST — a `skills` scope naming the same code is the
|
|
885
|
+
* more specific statement and wins.
|
|
556
886
|
* - `skills.defaults.validation.severity` — the PROJECT-WIDE map, applied to
|
|
557
887
|
* every result this run produced, including `claude-plugin` and `marketplace`
|
|
558
888
|
* ones. It used to be applied only to skill-typed results, which left the
|
|
@@ -587,18 +917,27 @@ function buildFilteredResult(result, filteredIssues) {
|
|
|
587
917
|
* @returns New results array with ignored codes removed from issues
|
|
588
918
|
*/
|
|
589
919
|
function applySeverityFilter(results, config) {
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
const
|
|
920
|
+
const skillsConfig = config?.skills;
|
|
921
|
+
// NOT gated on `config.skills` existing. It was, and that made the resources
|
|
922
|
+
// dial below inert for exactly the projects that have only one — a repo of
|
|
923
|
+
// plugins declares no skills at all.
|
|
924
|
+
const resourceSeverity = config?.resources?.validation?.severity ?? {};
|
|
925
|
+
const defaultSeverity = skillsConfig?.defaults?.validation?.severity ?? {};
|
|
926
|
+
const projectSeverity = { ...resourceSeverity, ...defaultSeverity };
|
|
927
|
+
// `.filter` after `.map`, not `.map` alone: a synthetic unreadable-path result
|
|
928
|
+
// exists ONLY to carry its finding, so once the adopter has set
|
|
929
|
+
// `severity.SCAN_PATH_UNREADABLE: ignore` the result has nothing left to say.
|
|
930
|
+
// Keeping it published `status: success` for a path the scan never read — the
|
|
931
|
+
// report positively asserting that an unread path PASSED, which is a stronger
|
|
932
|
+
// falsehood than the count inflation noted on `unreadablePathResult`. Dropping
|
|
933
|
+
// it is what "ignore" already means everywhere else.
|
|
595
934
|
return results.map(result => {
|
|
596
935
|
const skillName = SKILL_RESULT_TYPES.has(result.type) ? result.metadata?.name : undefined;
|
|
597
936
|
const perSkillSeverity = skillName === undefined
|
|
598
937
|
? {}
|
|
599
|
-
: (skillsConfig
|
|
600
|
-
// Merge:
|
|
601
|
-
const effectiveSeverity = { ...
|
|
938
|
+
: (skillsConfig?.config?.[skillName]?.validation?.severity ?? {});
|
|
939
|
+
// Merge, least specific first: resources → skills.defaults → skills.config.<name>
|
|
940
|
+
const effectiveSeverity = { ...projectSeverity, ...perSkillSeverity };
|
|
602
941
|
if (Object.keys(effectiveSeverity).length === 0) {
|
|
603
942
|
return result;
|
|
604
943
|
}
|
|
@@ -614,7 +953,21 @@ function applySeverityFilter(results, config) {
|
|
|
614
953
|
return result;
|
|
615
954
|
}
|
|
616
955
|
return buildFilteredResult(result, resolvedIssues);
|
|
617
|
-
});
|
|
956
|
+
}).filter(result => !isEmptiedUnreadablePathResult(result));
|
|
957
|
+
}
|
|
958
|
+
/**
|
|
959
|
+
* A synthetic unreadable-path result whose only finding was ignored away.
|
|
960
|
+
*
|
|
961
|
+
* Keyed on the shape this module itself produces — `type: 'unknown'`, no
|
|
962
|
+
* metadata, and now no issues — rather than on a marker field, because
|
|
963
|
+
* `ValidationResult` is a shared contract and this is the one producer that needs
|
|
964
|
+
* to be recognised. A genuinely-unknown resource from `unified-validator` carries
|
|
965
|
+
* metadata or issues and is unaffected.
|
|
966
|
+
*/
|
|
967
|
+
function isEmptiedUnreadablePathResult(result) {
|
|
968
|
+
return result.type === 'unknown'
|
|
969
|
+
&& result.issues.length === 0
|
|
970
|
+
&& result.metadata === undefined;
|
|
618
971
|
}
|
|
619
972
|
/**
|
|
620
973
|
* Resolve `--settings`-driven EffectiveSettings if both --settings and
|
|
@@ -647,17 +1000,47 @@ export async function buildAuditReport(scanPath, options, startTime, logger) {
|
|
|
647
1000
|
const recursive = options.recursive !== false;
|
|
648
1001
|
logger.debug(`Auditing resources at: ${scanPath}`);
|
|
649
1002
|
const scanRoot = deriveScanRoot(scanPath);
|
|
650
|
-
const
|
|
1003
|
+
const scanned = await getValidationResults(scanPath, recursive, options, logger, scanRoot);
|
|
1004
|
+
// Build config-aware context scoped to the single derived config root —
|
|
1005
|
+
// used by compat analysis to resolve the plugin-level `targets` union.
|
|
1006
|
+
// VAT's design: one config per project, no composition.
|
|
1007
|
+
//
|
|
1008
|
+
// BEFORE the report is assembled, not after: this is the lane that expands
|
|
1009
|
+
// `skills.include` even when the scan reached no SKILL.md under the config,
|
|
1010
|
+
// and a refusal it meets is recorded in the same ledger the append below
|
|
1011
|
+
// reads. Built afterwards, that finding reached stderr and not the document.
|
|
1012
|
+
const vatContextForCompat = await buildVATProjectContext(deriveConfigRoot(scanPath), logger, scanRoot);
|
|
1013
|
+
// See {@link withDegradedConfigFindings} for why these are appended, not merged.
|
|
1014
|
+
const rawResults = withDegradedConfigFindings(scanned);
|
|
651
1015
|
// Load config for severity filtering (audit ignores allow; only severity matters).
|
|
652
|
-
|
|
1016
|
+
//
|
|
1017
|
+
// Guarded, and the ordering is why: the scan above has ALREADY run and collected
|
|
1018
|
+
// every finding by this point. An unreadable or malformed config here threw past
|
|
1019
|
+
// all of it and ended the command with `status: error` and zero findings —
|
|
1020
|
+
// discarding completed work to report a problem with a file that only decides
|
|
1021
|
+
// which findings to *hide*. Every other `loadConfig` in this file is already
|
|
1022
|
+
// wrapped for the same reason; this one was missed.
|
|
1023
|
+
//
|
|
1024
|
+
// Falling back to no config means no severity overrides are applied, so findings
|
|
1025
|
+
// are reported at their default severity. That is the safe direction: it can
|
|
1026
|
+
// only show more than the adopter asked for, never less.
|
|
1027
|
+
let config;
|
|
1028
|
+
try {
|
|
1029
|
+
config = loadConfig(deriveConfigRoot(scanPath));
|
|
1030
|
+
}
|
|
1031
|
+
catch (err) {
|
|
1032
|
+
// `warn`, not `debug`. Falling back to no config is the safe direction — it
|
|
1033
|
+
// can only show more than the adopter asked for, never less — but it means
|
|
1034
|
+
// every `validation.severity` override they wrote is silently inert. Before
|
|
1035
|
+
// this guard the command failed loudly and told them; degrading without a word
|
|
1036
|
+
// would trade a crash for a config that quietly stopped working, which is the
|
|
1037
|
+
// harder problem to notice.
|
|
1038
|
+
logger.warn(`Config could not be read; severity overrides are not applied: ${String(err)}`);
|
|
1039
|
+
}
|
|
653
1040
|
// Apply severity filtering: hide codes whose effective severity is 'ignore'.
|
|
654
|
-
// Allow is deliberately NOT applied — audit
|
|
1041
|
+
// Allow is deliberately NOT applied — audit shows every finding.
|
|
655
1042
|
const results = applySeverityFilter(rawResults, config);
|
|
656
1043
|
const effectiveSettings = await resolveEffectiveSettings(options, scanPath, logger);
|
|
657
|
-
// Build config-aware context scoped to the single derived config root —
|
|
658
|
-
// used by compat analysis to resolve the plugin-level `targets` union.
|
|
659
|
-
// VAT's design: one config per project, no composition.
|
|
660
|
-
const vatContextForCompat = await buildVATProjectContext(deriveConfigRoot(scanPath), logger);
|
|
661
1044
|
// Run compatibility analysis if --compat flag is set
|
|
662
1045
|
const compatMap = options.compat
|
|
663
1046
|
? await runCompatAnalysis(results, logger, scanRoot, effectiveSettings, vatContextForCompat)
|
|
@@ -718,7 +1101,6 @@ export async function auditCommand(targetPath, options) {
|
|
|
718
1101
|
// cloned https://github.com/<that>.git instead, reaching out to the
|
|
719
1102
|
// network with a name derived from the caller's own directory layout.
|
|
720
1103
|
// Shorthand is a fallback for arguments that name nothing on disk.
|
|
721
|
-
// eslint-disable-next-line security/detect-non-literal-fs-filename -- targetPath is the user's own audit target; existence is the question being asked
|
|
722
1104
|
if (targetPath !== undefined && !fsExistsSync(targetPath) && isGitUrl(targetPath)) {
|
|
723
1105
|
await runUrlAudit(targetPath, options);
|
|
724
1106
|
return;
|
|
@@ -730,10 +1112,78 @@ export async function auditCommand(targetPath, options) {
|
|
|
730
1112
|
return;
|
|
731
1113
|
}
|
|
732
1114
|
const scanPath = targetPath ? safePath.resolve(targetPath) : process.cwd();
|
|
1115
|
+
const unusable = await unusableRootReason(scanPath);
|
|
1116
|
+
if (unusable !== undefined) {
|
|
1117
|
+
// `return` the call though its type is `never`: under test `process.exit`
|
|
1118
|
+
// is a spy that returns, and a bare call would fall through into the scan.
|
|
1119
|
+
return handleExpectedFailure(unusable, ExitCode.ERROR, startTime);
|
|
1120
|
+
}
|
|
733
1121
|
await runAuditAtPath(scanPath, options);
|
|
734
1122
|
}
|
|
735
1123
|
catch (error) {
|
|
736
|
-
|
|
1124
|
+
// 'Audit', not 'AgentAudit'. The old name was copy-pasted from the agent
|
|
1125
|
+
// command family and named a command that does not exist, so the one line an
|
|
1126
|
+
// operator has to search for did not match what they typed.
|
|
1127
|
+
handleCommandError(error, logger, startTime, 'Audit');
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
/**
|
|
1131
|
+
* Why the ROOT argument cannot be audited at all, or `undefined` when the scan
|
|
1132
|
+
* may start.
|
|
1133
|
+
*
|
|
1134
|
+
* The exit-code contract every verb shares: a path that does not exist, or a
|
|
1135
|
+
* file no audit lane recognises, is the INVOCATION's mistake — `ExitCode.ERROR`
|
|
1136
|
+
* (2) before any scan, the same ending `vat resources validate` and `vat skill
|
|
1137
|
+
* review` give the same argument. Until this check the audit ran anyway and
|
|
1138
|
+
* published `UNKNOWN_FORMAT` as a finding at exit 1 with `filesScanned: 1`: a
|
|
1139
|
+
* typo in a CI step read as "the tree failed its gate", and the denominator
|
|
1140
|
+
* counted a file that was never there.
|
|
1141
|
+
*
|
|
1142
|
+
* 🚨 **And a root DIRECTORY the OS refuses to list.** It used to be left to
|
|
1143
|
+
* the scan, which degraded it to `SCAN_PATH_UNREADABLE` and ended on the
|
|
1144
|
+
* zero-files refusal at exit 1 — while `vat resources validate`, `check`,
|
|
1145
|
+
* `query` and `scan` all end the same argument at exit 2. One outcome, two
|
|
1146
|
+
* codes. Nothing under an unlistable root can be examined, so the audit could
|
|
1147
|
+
* not do its job: that is the INVOCATION's ending, the same as a missing path.
|
|
1148
|
+
*
|
|
1149
|
+
* ONLY the root. A sub-path inside a readable tree is never the invocation's
|
|
1150
|
+
* fault and stays a finding. Pinned by the `audit → error` rows of
|
|
1151
|
+
* `exit-codes.system.test.ts` and the unreadable-root row of
|
|
1152
|
+
* `exit-code-matrix.system.test.ts`.
|
|
1153
|
+
*/
|
|
1154
|
+
async function unusableRootReason(scanPath) {
|
|
1155
|
+
let isDirectory;
|
|
1156
|
+
try {
|
|
1157
|
+
isDirectory = fs.lstatSync(scanPath).isDirectory();
|
|
1158
|
+
}
|
|
1159
|
+
catch (error) {
|
|
1160
|
+
if (isPathAbsentError(error))
|
|
1161
|
+
return `Path does not exist: ${scanPath}`;
|
|
1162
|
+
return undefined;
|
|
1163
|
+
}
|
|
1164
|
+
if (isDirectory)
|
|
1165
|
+
return unlistableRootReason(scanPath);
|
|
1166
|
+
if (detectFormat(scanPath) === RESOURCE_TYPE_AGENT_SKILL)
|
|
1167
|
+
return undefined;
|
|
1168
|
+
if ((await detectResourceFormat(scanPath)).type !== 'unknown')
|
|
1169
|
+
return undefined;
|
|
1170
|
+
return `Path is not a resource this command can audit (a SKILL.md, a plugin or marketplace directory, or a Claude registry file): ${scanPath}`;
|
|
1171
|
+
}
|
|
1172
|
+
/**
|
|
1173
|
+
* Why a root DIRECTORY cannot be audited, or `undefined` when it can be listed.
|
|
1174
|
+
*
|
|
1175
|
+
* @param scanPath - The root the operator named, known to be a directory
|
|
1176
|
+
* @returns The refusal, or undefined
|
|
1177
|
+
*/
|
|
1178
|
+
function unlistableRootReason(scanPath) {
|
|
1179
|
+
try {
|
|
1180
|
+
// A probe, not a listing: the crawl owns enumeration, this only asks whether it can start.
|
|
1181
|
+
fs.accessSync(scanPath, fs.constants.R_OK | fs.constants.X_OK);
|
|
1182
|
+
return undefined;
|
|
1183
|
+
}
|
|
1184
|
+
catch (error) {
|
|
1185
|
+
const code = error.code ?? 'unknown error';
|
|
1186
|
+
return `Path cannot be read (${code}): ${scanPath}`;
|
|
737
1187
|
}
|
|
738
1188
|
}
|
|
739
1189
|
/**
|
|
@@ -788,7 +1238,7 @@ async function runInventoryDetectors(scanPath, type, locationRoot, prebuiltInv)
|
|
|
788
1238
|
];
|
|
789
1239
|
}
|
|
790
1240
|
if (type === 'marketplace') {
|
|
791
|
-
const inv = prebuiltInv?.kind === 'marketplace' ? prebuiltInv : await extractClaudeMarketplaceInventory(scanPath);
|
|
1241
|
+
const inv = prebuiltInv?.kind === 'marketplace' ? prebuiltInv : await extractClaudeMarketplaceInventory(scanPath, { gitTrackerSource: gitTrackerForProjectRoot });
|
|
792
1242
|
return detectMarketplacePluginSourceMissing(inv, locationRoot);
|
|
793
1243
|
}
|
|
794
1244
|
return [];
|
|
@@ -838,22 +1288,50 @@ async function appendPluginInventoryToSurfaceResults(surfaceResults, scanPath, l
|
|
|
838
1288
|
}
|
|
839
1289
|
/**
|
|
840
1290
|
* Surface `inventory.parseErrors[]` as `PLUGIN_INVALID_JSON` findings on
|
|
841
|
-
* `result`, skipping the
|
|
842
|
-
*
|
|
843
|
-
*
|
|
1291
|
+
* `result`, skipping the entries another lane already files under their own
|
|
1292
|
+
* code:
|
|
1293
|
+
*
|
|
1294
|
+
* - the plugin.json manifest entry, covered by `validatePlugin`;
|
|
1295
|
+
* - every `SKILL.md` entry. The skill extractor records a SKILL.md it could
|
|
1296
|
+
* not open, could not parse, or could not link-walk on the plugin's
|
|
1297
|
+
* `parseErrors[]` — and each of those is the SKILL's finding, filed on the
|
|
1298
|
+
* skill by its own validation (`SCAN_PATH_UNREADABLE`,
|
|
1299
|
+
* `SKILL_MISSING_FRONTMATTER`) in both the plugin-target lane
|
|
1300
|
+
* (`validatePluginSkillsViaInventory`, over the same inventory) and the
|
|
1301
|
+
* directory lane (the walk reaches every SKILL.md beneath the plugin).
|
|
1302
|
+
* Re-filing it here was a second copy under a code that names a file it is
|
|
1303
|
+
* not (plugin.json) and a format it is not (JSON), at error severity beside
|
|
1304
|
+
* the skill lane's warning, and carrying the build host's absolute path.
|
|
1305
|
+
*
|
|
1306
|
+
* What remains — hooks/hooks.json, .mcp.json, a directory the crawl could not
|
|
1307
|
+
* list — is appended with error severity when the SUBJECT is defective, and
|
|
1308
|
+
* as `SCAN_PATH_UNREADABLE` (warning) when the row is marked `unreadable`
|
|
1309
|
+
* (the OS refused the path); the message is spelled scan-root-relative
|
|
1310
|
+
* wherever it names the file.
|
|
844
1311
|
*/
|
|
845
1312
|
function appendInventoryParseErrors(result, inv, locationRoot) {
|
|
846
1313
|
const pluginJsonSuffix = safePath.join('.claude-plugin', 'plugin.json');
|
|
1314
|
+
// One refused path, one refusal: the distributed-tree detector crawls the
|
|
1315
|
+
// same directories and files the ones it cannot list on this same result.
|
|
1316
|
+
const refusalsFiled = new Set(result.issues.filter((issue) => issue.code === 'SCAN_PATH_UNREADABLE').map((issue) => issue.location));
|
|
847
1317
|
const parseIssues = [];
|
|
848
1318
|
for (const err of inv.parseErrors) {
|
|
849
|
-
if (err.path.endsWith(pluginJsonSuffix))
|
|
1319
|
+
if (err.path.endsWith(pluginJsonSuffix) || basename(err.path) === 'SKILL.md')
|
|
850
1320
|
continue;
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
}
|
|
1321
|
+
const message = withRelativePath(err.message, err.path, locationRoot);
|
|
1322
|
+
const location = issueLocation(err.path, locationRoot);
|
|
1323
|
+
if (err.unreadable !== true) {
|
|
1324
|
+
parseIssues.push({ severity: 'error', code: 'PLUGIN_INVALID_JSON', message, location });
|
|
1325
|
+
continue;
|
|
1326
|
+
}
|
|
1327
|
+
// A path the OS refused was not examined; that is the run degrading, not
|
|
1328
|
+
// a defect of the plugin, and it is filed at the severity every other lane
|
|
1329
|
+
// gives a refusal. Under `PLUGIN_INVALID_JSON` (error) an `EACCES` on one
|
|
1330
|
+
// skill directory failed the whole audit over a mode bit.
|
|
1331
|
+
if (refusalsFiled.has(location))
|
|
1332
|
+
continue;
|
|
1333
|
+
refusalsFiled.add(location);
|
|
1334
|
+
parseIssues.push(materializeIssue('SCAN_PATH_UNREADABLE', { location, detail: `${location}: ${message}` }));
|
|
857
1335
|
}
|
|
858
1336
|
// Status was hand-set to 'error' here while `issueCounts` and `summary` kept
|
|
859
1337
|
// describing the pre-append list. One appender derives all three.
|
|
@@ -869,7 +1347,119 @@ function appendInventoryParseErrors(result, inv, locationRoot) {
|
|
|
869
1347
|
* location reads, or a run spanning several configs emits one document in
|
|
870
1348
|
* several coordinate systems. Recursion passes it through unchanged.
|
|
871
1349
|
*/
|
|
1350
|
+
/**
|
|
1351
|
+
* Validate ONE audit subject, degrading if the filesystem refuses it.
|
|
1352
|
+
*
|
|
1353
|
+
* THE boundary guard, and deliberately the only one of its kind. The first
|
|
1354
|
+
* version of this fix guarded the directory walk alone, which left every other
|
|
1355
|
+
* way into a validator unprotected: a `SKILL.md` named directly on the command
|
|
1356
|
+
* line, a plugin directory, a marketplace, each surface of a multi-surface tree.
|
|
1357
|
+
* All of them still ended the whole run with `status: error`, exit 2 and zero
|
|
1358
|
+
* findings — the exact symptom of the unreadable-directory defect, in the lanes
|
|
1359
|
+
* nobody had checked.
|
|
1360
|
+
*
|
|
1361
|
+
* Guarding the seven dispatch sites individually would have been the same mistake
|
|
1362
|
+
* a seventh time. This function is what every one of them is reached through, so
|
|
1363
|
+
* it is where "the environment refused this subject" becomes a finding instead of
|
|
1364
|
+
* an abort. It is also the recursion point — `recurseIntoMarketplacePlugins` and
|
|
1365
|
+
* the `--user` target loop both re-enter here — so an unreadable plugin costs its
|
|
1366
|
+
* own subject and nothing else.
|
|
1367
|
+
*
|
|
1368
|
+
* The walk keeps its own per-entry guard: this one would answer at whole-subject
|
|
1369
|
+
* granularity, discarding findings already collected from readable siblings, and
|
|
1370
|
+
* degrading as narrowly as possible is the entire point.
|
|
1371
|
+
*/
|
|
872
1372
|
export async function getValidationResults(scanPath, recursive, options, logger, locationRoot) {
|
|
1373
|
+
try {
|
|
1374
|
+
return dedupeUnreadablePathResults(await validateAuditSubject(scanPath, recursive, options, logger, locationRoot));
|
|
1375
|
+
}
|
|
1376
|
+
catch (error) {
|
|
1377
|
+
// Only the filesystem refusing the subject degrades; a defect in a validator
|
|
1378
|
+
// must still fail loudly rather than be reported as a permissions problem.
|
|
1379
|
+
if (!isFilesystemAccessError(error))
|
|
1380
|
+
throw error;
|
|
1381
|
+
logger.debug(`Unreadable audit subject: ${scanPath}`);
|
|
1382
|
+
return [unreadablePathResult(scanPath, error, locationRoot)];
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
/**
|
|
1386
|
+
* One refused path, one `SCAN_PATH_UNREADABLE` — however many lanes met it.
|
|
1387
|
+
*
|
|
1388
|
+
* Two lanes can reach the same unreadable directory in one run: the directory
|
|
1389
|
+
* walk (`scanDirectory`, which files the synthetic {@link unreadablePathResult})
|
|
1390
|
+
* and the distributed-tree detector, which crawls a skill's own directory at any
|
|
1391
|
+
* depth and reports what it could not list as an issue ON the skill's result.
|
|
1392
|
+
* Their populations overlap, but neither contains the other: the walk skips
|
|
1393
|
+
* what `--exclude`, the project's `resources.exclude` and gitignore skip, and
|
|
1394
|
+
* does not descend at all under `--no-recursive` or when the SKILL.md is named
|
|
1395
|
+
* directly — the detector enters all of those. So "the walk already reports
|
|
1396
|
+
* everything under the scan root" is false in both directions, and a rule that
|
|
1397
|
+
* silenced one lane by geography would drop the refusal in exactly the cases
|
|
1398
|
+
* where it is the only report. The rule is instead keyed on the FACT: the
|
|
1399
|
+
* code and the location, which both lanes derive from the same directory and
|
|
1400
|
+
* the same anchor root.
|
|
1401
|
+
*
|
|
1402
|
+
* The hosted issue wins and the synthetic result is dropped, not the reverse:
|
|
1403
|
+
* the detector's copy says which SKILL's crawl was cut short, which the
|
|
1404
|
+
* synthetic result cannot, and it is the only copy the direct-target lane ever
|
|
1405
|
+
* produces — so keeping it makes the two lanes' reports the same shape. A
|
|
1406
|
+
* synthetic result that carried nothing but the duplicate is removed whole; one
|
|
1407
|
+
* that somehow carried more keeps its other issues (it cannot today, but the
|
|
1408
|
+
* filter says what it means rather than assuming).
|
|
1409
|
+
*
|
|
1410
|
+
* A THIRD lane files synthetic results too: skill discovery under a governing
|
|
1411
|
+
* config, which refuses a directory its `skills.include` cannot list and is
|
|
1412
|
+
* recorded through {@link recordRefusedDiscovery}. In the directory lane the
|
|
1413
|
+
* walk has already filed its own synthetic result on that same directory, so
|
|
1414
|
+
* two synthetic results can carry one fact. Among synthetics the FIRST wins and
|
|
1415
|
+
* later copies of the same location are dropped — the walk's copy carries the
|
|
1416
|
+
* raw errno, the discovery's copy the crawl's remedy sentence, and the stderr
|
|
1417
|
+
* warning carries the latter either way.
|
|
1418
|
+
*/
|
|
1419
|
+
function dedupeUnreadablePathResults(results) {
|
|
1420
|
+
const reported = new Set();
|
|
1421
|
+
for (const result of results) {
|
|
1422
|
+
if (result.type === 'unknown')
|
|
1423
|
+
continue;
|
|
1424
|
+
for (const issue of result.issues) {
|
|
1425
|
+
if (issue.code === 'SCAN_PATH_UNREADABLE')
|
|
1426
|
+
reported.add(issue.location ?? '');
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
return results.flatMap((result) => {
|
|
1430
|
+
if (result.type !== 'unknown')
|
|
1431
|
+
return [result];
|
|
1432
|
+
const kept = withoutHostedRefusals(result, reported);
|
|
1433
|
+
for (const survivor of kept) {
|
|
1434
|
+
for (const issue of survivor.issues) {
|
|
1435
|
+
if (issue.code === 'SCAN_PATH_UNREADABLE')
|
|
1436
|
+
reported.add(issue.location ?? '');
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
return kept;
|
|
1440
|
+
});
|
|
1441
|
+
}
|
|
1442
|
+
/**
|
|
1443
|
+
* A synthetic result minus the refusals some hosted result already carries —
|
|
1444
|
+
* as an empty array when nothing is left, so {@link dedupeUnreadablePathResults}
|
|
1445
|
+
* can `flatMap` it away.
|
|
1446
|
+
*/
|
|
1447
|
+
function withoutHostedRefusals(result, hosted) {
|
|
1448
|
+
const issues = result.issues.filter((issue) => !(issue.code === 'SCAN_PATH_UNREADABLE' && hosted.has(issue.location ?? '')));
|
|
1449
|
+
if (issues.length === 0)
|
|
1450
|
+
return [];
|
|
1451
|
+
if (issues.length === result.issues.length)
|
|
1452
|
+
return [result];
|
|
1453
|
+
const issueCounts = countBySeverity(issues);
|
|
1454
|
+
return [{
|
|
1455
|
+
...result,
|
|
1456
|
+
status: calculateValidationStatus(issues),
|
|
1457
|
+
summary: formatCountsSummary(issueCounts),
|
|
1458
|
+
issues,
|
|
1459
|
+
issueCounts,
|
|
1460
|
+
}];
|
|
1461
|
+
}
|
|
1462
|
+
async function validateAuditSubject(scanPath, recursive, options, logger, locationRoot) {
|
|
873
1463
|
const format = detectFormat(scanPath);
|
|
874
1464
|
// Special handling for direct SKILL.md file
|
|
875
1465
|
if (format === RESOURCE_TYPE_AGENT_SKILL) {
|
|
@@ -923,7 +1513,7 @@ export async function getValidationResults(scanPath, recursive, options, logger,
|
|
|
923
1513
|
// recursion into co-located, path-source plugins. discovered.plugins[]
|
|
924
1514
|
// is populated only for path sources that exist on disk (git/npm/unknown
|
|
925
1515
|
// sources are declarations only and stay out of scope).
|
|
926
|
-
const marketplaceInv = await extractClaudeMarketplaceInventory(scanPath);
|
|
1516
|
+
const marketplaceInv = await extractClaudeMarketplaceInventory(scanPath, { gitTrackerSource: gitTrackerForProjectRoot });
|
|
927
1517
|
const marketplaceInventoryIssues = await runInventoryDetectors(scanPath, 'marketplace', locationRoot, marketplaceInv);
|
|
928
1518
|
appendIssues(result, marketplaceInventoryIssues);
|
|
929
1519
|
logger.debug(`Inventory detectors emitted ${marketplaceInventoryIssues.length.toString()} issues for marketplace at ${scanPath}`);
|
|
@@ -933,26 +1523,45 @@ export async function getValidationResults(scanPath, recursive, options, logger,
|
|
|
933
1523
|
return [result];
|
|
934
1524
|
}
|
|
935
1525
|
// If unknown format, check if it's a directory we can scan
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
// merged into `options.exclude`, whose patterns are relative to the
|
|
944
|
-
// directory the operator named. See resolveProjectExcludes.
|
|
945
|
-
return scanDirectory(scanPath, recursive, options, logger, await resolveScanContext(scanPath, locationRoot, logger));
|
|
946
|
-
}
|
|
947
|
-
}
|
|
948
|
-
catch {
|
|
949
|
-
// Path doesn't exist or not accessible, let validate() handle it
|
|
1526
|
+
if (await isScannableDirectory(scanPath)) {
|
|
1527
|
+
logger.debug('Scanning directory for resources');
|
|
1528
|
+
// The project's `resources.exclude` is applied by the scan context,
|
|
1529
|
+
// on the project-root basis its globs were written against — NOT
|
|
1530
|
+
// merged into `options.exclude`, whose patterns are relative to the
|
|
1531
|
+
// directory the operator named. See resolveProjectExcludes.
|
|
1532
|
+
return scanDirectory(scanPath, recursive, options, logger, await resolveScanContext(scanPath, locationRoot, logger));
|
|
950
1533
|
}
|
|
951
1534
|
// Unknown resource type - use unified validator which will return appropriate error
|
|
952
1535
|
logger.debug(`Unknown resource type at: ${scanPath}`);
|
|
953
1536
|
const result = await validate(scanPath, { locationRoot });
|
|
954
1537
|
return [result];
|
|
955
1538
|
}
|
|
1539
|
+
/**
|
|
1540
|
+
* Whether `scanPath` is a directory the walk can enter.
|
|
1541
|
+
*
|
|
1542
|
+
* `false` for a file and for NOTHING — `validate()` then names the path as
|
|
1543
|
+
* unknown or absent, which is the answer the operator can act on. A refusal on
|
|
1544
|
+
* the stat is neither: it propagates to `getValidationResults`, whose own catch
|
|
1545
|
+
* files it as `SCAN_PATH_UNREADABLE`, so the audit still degrades (the run
|
|
1546
|
+
* completes, the refusal a finding) rather than refusing the run.
|
|
1547
|
+
*
|
|
1548
|
+
* 🪤 The stat used to share ONE `try` with `scanDirectory` itself, and the
|
|
1549
|
+
* catch was bare. Every throw from the scan — a crawl refusal, a `TypeError`
|
|
1550
|
+
* two frames down — was absorbed into "let validate() handle it", and
|
|
1551
|
+
* `validate()` then reported the directory as an unknown resource format. A
|
|
1552
|
+
* scan that examined nothing was reported as a directory that was nothing.
|
|
1553
|
+
*/
|
|
1554
|
+
async function isScannableDirectory(scanPath) {
|
|
1555
|
+
const fsp = await import('node:fs/promises');
|
|
1556
|
+
try {
|
|
1557
|
+
return (await fsp.stat(scanPath)).isDirectory();
|
|
1558
|
+
}
|
|
1559
|
+
catch (error) {
|
|
1560
|
+
if (isPathAbsentError(error))
|
|
1561
|
+
return false;
|
|
1562
|
+
throw error;
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
956
1565
|
/**
|
|
957
1566
|
* Inventory-driven plugin skill walker. Builds the plugin inventory via
|
|
958
1567
|
* `extractClaudePluginInventory` and dispatches `validateSingleSkill` for each
|
|
@@ -972,9 +1581,26 @@ async function validatePluginSkillsViaInventory(scanPath, options, logger, locat
|
|
|
972
1581
|
continue;
|
|
973
1582
|
}
|
|
974
1583
|
logger.debug(` Validating plugin-bundled skill (inventory): ${skill.files.skillMd}`);
|
|
975
|
-
//
|
|
976
|
-
//
|
|
977
|
-
|
|
1584
|
+
// Guarded PER SKILL, not left to the boundary. The boundary guard answers at
|
|
1585
|
+
// whole-subject granularity, so one unreadable bundled `SKILL.md` discarded
|
|
1586
|
+
// the plugin's own manifest findings and every readable sibling skill with
|
|
1587
|
+
// it — 4 results down to 1 — and anchored the finding at the plugin root,
|
|
1588
|
+
// which is perfectly readable. That contradicted this code's own promise
|
|
1589
|
+
// that an unreadable path "costs its own subject and nothing else", and the
|
|
1590
|
+
// documented promise that "a skill beside the unreadable path is validated
|
|
1591
|
+
// exactly as if it were not there". Degrading as narrowly as the structure
|
|
1592
|
+
// allows is the whole point; here the skill is the unit.
|
|
1593
|
+
try {
|
|
1594
|
+
// No crawl: `validatePlugin` already scanned the whole plugin tree, nested
|
|
1595
|
+
// skill directories included, so this lane would duplicate its findings.
|
|
1596
|
+
results.push(await validateSingleSkill(skill.files.skillMd, options, logger, locationRoot, false));
|
|
1597
|
+
}
|
|
1598
|
+
catch (error) {
|
|
1599
|
+
if (!isFilesystemAccessError(error))
|
|
1600
|
+
throw error;
|
|
1601
|
+
logger.debug(` Unreadable plugin-bundled skill: ${skill.files.skillMd}`);
|
|
1602
|
+
results.push(unreadablePathResult(skill.files.skillMd, error, locationRoot));
|
|
1603
|
+
}
|
|
978
1604
|
}
|
|
979
1605
|
return results;
|
|
980
1606
|
}
|
|
@@ -1001,9 +1627,9 @@ function resolveConfigTargetsForPlugin(pluginDir, vatContext) {
|
|
|
1001
1627
|
const union = new Set();
|
|
1002
1628
|
for (const [skillPathAbs, packagingConfig] of vatContext.skillConfigs) {
|
|
1003
1629
|
const rel = safePath.relative(pluginDirAbs, skillPathAbs);
|
|
1004
|
-
// Skill lives inside the plugin directory iff relative path
|
|
1005
|
-
//
|
|
1006
|
-
if (rel === '' ||
|
|
1630
|
+
// Skill lives inside the plugin directory iff the relative path neither
|
|
1631
|
+
// climbs out nor (on Windows, cross-drive) comes back absolute.
|
|
1632
|
+
if (rel === '' || relativeEscapesRoot(rel))
|
|
1007
1633
|
continue;
|
|
1008
1634
|
const skillTargets = packagingConfig.targets;
|
|
1009
1635
|
if (skillTargets === undefined)
|
|
@@ -1015,7 +1641,15 @@ function resolveConfigTargetsForPlugin(pluginDir, vatContext) {
|
|
|
1015
1641
|
return union.size === 0 ? undefined : [...union];
|
|
1016
1642
|
}
|
|
1017
1643
|
/**
|
|
1018
|
-
*
|
|
1644
|
+
* `message`, with `absolutePath` spelled root-relative wherever it appears.
|
|
1645
|
+
* An OS refusal names the path it refused, absolutely; every other path in
|
|
1646
|
+
* the document is relative to `root`, and this one leaks the build host.
|
|
1647
|
+
*/
|
|
1648
|
+
function withRelativePath(message, absolutePath, locationRoot) {
|
|
1649
|
+
return message.replaceAll(absolutePath, issueLocation(absolutePath, locationRoot) || '.');
|
|
1650
|
+
}
|
|
1651
|
+
/**
|
|
1652
|
+
* Run compatibility analysis on plugin results and return a map of path -> PluginCompatEntry.
|
|
1019
1653
|
* Non-plugin results are skipped silently.
|
|
1020
1654
|
* When effectiveSettings is provided, also runs settings conflict detection.
|
|
1021
1655
|
*
|
|
@@ -1036,67 +1670,99 @@ export async function runCompatAnalysis(results, logger, locationRoot, effective
|
|
|
1036
1670
|
for (const result of results) {
|
|
1037
1671
|
if (result.type !== RESOURCE_TYPE_CLAUDE_PLUGIN)
|
|
1038
1672
|
continue;
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
: { configTargets };
|
|
1045
|
-
const compat = await analyzeCompatibility(result.path, locationRoot, analyzeOptions);
|
|
1046
|
-
// Settings conflict detection (when --settings flag is used)
|
|
1047
|
-
if (effectiveSettings === undefined) {
|
|
1048
|
-
compatMap.set(result.path, compat);
|
|
1049
|
-
}
|
|
1050
|
-
else {
|
|
1051
|
-
try {
|
|
1052
|
-
const settingsConflicts = await checkSettingsCompatibility(result.path, effectiveSettings);
|
|
1053
|
-
compatMap.set(result.path, { ...compat, settingsConflicts });
|
|
1054
|
-
}
|
|
1055
|
-
catch (settingsErr) {
|
|
1056
|
-
logger.debug(`Settings compat check skipped for ${result.path}: ${String(settingsErr)}`);
|
|
1057
|
-
compatMap.set(result.path, compat);
|
|
1058
|
-
}
|
|
1059
|
-
}
|
|
1060
|
-
}
|
|
1061
|
-
catch (err) {
|
|
1062
|
-
// Log but do not fail the audit — compat analysis is best-effort
|
|
1063
|
-
logger.debug(`Compatibility analysis skipped for ${result.path}: ${String(err)}`);
|
|
1673
|
+
const entry = {
|
|
1674
|
+
compat: await analyzeOrExplain(result.path, locationRoot, vatContext, logger),
|
|
1675
|
+
};
|
|
1676
|
+
if (effectiveSettings !== undefined) {
|
|
1677
|
+
entry.settings = await settingsVerdict(result.path, effectiveSettings, locationRoot, logger);
|
|
1064
1678
|
}
|
|
1679
|
+
compatMap.set(result.path, entry);
|
|
1065
1680
|
}
|
|
1066
1681
|
return compatMap;
|
|
1067
1682
|
}
|
|
1683
|
+
/**
|
|
1684
|
+
* The analyzer's result for one plugin, or — when it threw — the reason, said
|
|
1685
|
+
* on stderr and carried into the document. Compat analysis is best-effort:
|
|
1686
|
+
* it never fails the audit, but a plugin it could not analyze is reported as
|
|
1687
|
+
* exactly that, never as a plugin with no `compatibility:` block.
|
|
1688
|
+
*/
|
|
1689
|
+
async function analyzeOrExplain(pluginDir, locationRoot, vatContext, logger) {
|
|
1690
|
+
try {
|
|
1691
|
+
logger.debug(`Running compatibility analysis for: ${pluginDir}`);
|
|
1692
|
+
const configTargets = resolveConfigTargetsForPlugin(pluginDir, vatContext);
|
|
1693
|
+
const analyzeOptions = configTargets === undefined ? undefined : { configTargets };
|
|
1694
|
+
return await analyzeCompatibility(pluginDir, locationRoot, analyzeOptions);
|
|
1695
|
+
}
|
|
1696
|
+
catch (err) {
|
|
1697
|
+
const refused = typeof err.path === 'string' ? err.path : undefined;
|
|
1698
|
+
const raw = err instanceof Error ? err.message : String(err);
|
|
1699
|
+
const reason = refused === undefined ? raw : withRelativePath(raw, refused, locationRoot);
|
|
1700
|
+
logger.warn(`Compatibility analysis could not run for ${issueLocation(pluginDir, locationRoot) || '.'}: ${reason}`);
|
|
1701
|
+
return { analyzed: false, reason };
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1704
|
+
/**
|
|
1705
|
+
* One plugin's settings verdict: the checker's conflicts and the paths it
|
|
1706
|
+
* never saw — and, when the checker itself fails, the plugin as the one thing
|
|
1707
|
+
* unchecked, rather than no `settings:` block at all.
|
|
1708
|
+
*
|
|
1709
|
+
* The checker failing used to be `logger.debug` and a result with no settings
|
|
1710
|
+
* block, so a plugin the check could not run on was indistinguishable from one
|
|
1711
|
+
* the operator never asked about. A run that asked for `--settings` and got no
|
|
1712
|
+
* answer for a plugin is told so on stderr and in the document.
|
|
1713
|
+
*/
|
|
1714
|
+
async function settingsVerdict(pluginDir, effectiveSettings, locationRoot, logger) {
|
|
1715
|
+
try {
|
|
1716
|
+
return await checkSettingsCompatibility(pluginDir, effectiveSettings);
|
|
1717
|
+
}
|
|
1718
|
+
catch (settingsErr) {
|
|
1719
|
+
const reason = settingsErr instanceof Error ? settingsErr.message : String(settingsErr);
|
|
1720
|
+
logger.warn(`Settings compatibility could not be checked for ${issueLocation(pluginDir, locationRoot) || '.'}: ${reason}`);
|
|
1721
|
+
return { conflicts: [], unchecked: [{ path: pluginDir, reason }] };
|
|
1722
|
+
}
|
|
1723
|
+
}
|
|
1724
|
+
/**
|
|
1725
|
+
* The `settings:` block for one plugin, every path anchored at `locationRoot`.
|
|
1726
|
+
* `compatible` is derived from BOTH halves: zero conflicts over a skill the
|
|
1727
|
+
* check never read is not compatibility, it is silence.
|
|
1728
|
+
*/
|
|
1729
|
+
function renderSettingsBlock(check, locationRoot) {
|
|
1730
|
+
const settings = {
|
|
1731
|
+
compatible: check.conflicts.length === 0 && check.unchecked.length === 0,
|
|
1732
|
+
conflicts: check.conflicts,
|
|
1733
|
+
};
|
|
1734
|
+
if (check.unchecked.length > 0) {
|
|
1735
|
+
settings.unchecked = check.unchecked.map(({ path, reason }) => ({
|
|
1736
|
+
path: issueLocation(path, locationRoot) || '.',
|
|
1737
|
+
reason: withRelativePath(reason, path, locationRoot),
|
|
1738
|
+
}));
|
|
1739
|
+
}
|
|
1740
|
+
return settings;
|
|
1741
|
+
}
|
|
1068
1742
|
/**
|
|
1069
1743
|
* Merge compatibility analysis results into validation result output objects.
|
|
1070
1744
|
* Returns an array of plain objects ready for YAML serialization.
|
|
1071
|
-
*
|
|
1745
|
+
* A plugin the run asked `--settings` about gets a `settings:` block, whatever
|
|
1746
|
+
* the analyzer did.
|
|
1072
1747
|
*/
|
|
1073
|
-
function mergeCompatIntoResults(results, compatMap) {
|
|
1748
|
+
function mergeCompatIntoResults(results, compatMap, locationRoot) {
|
|
1074
1749
|
return results.map(r => {
|
|
1075
|
-
const
|
|
1076
|
-
if (
|
|
1750
|
+
const entry = compatMap.get(r.path);
|
|
1751
|
+
if (entry === undefined)
|
|
1077
1752
|
return r;
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
...r,
|
|
1083
|
-
compatibility: compatWithoutSettings,
|
|
1084
|
-
settings: {
|
|
1085
|
-
compatible: settingsConflicts.length === 0,
|
|
1086
|
-
conflicts: settingsConflicts,
|
|
1087
|
-
},
|
|
1088
|
-
};
|
|
1089
|
-
}
|
|
1090
|
-
return { ...r, compatibility: compat };
|
|
1753
|
+
const merged = { ...r, compatibility: entry.compat };
|
|
1754
|
+
if (entry.settings !== undefined)
|
|
1755
|
+
merged.settings = renderSettingsBlock(entry.settings, locationRoot);
|
|
1756
|
+
return merged;
|
|
1091
1757
|
});
|
|
1092
1758
|
}
|
|
1093
1759
|
/**
|
|
1094
1760
|
* Apply compatibility data to results if a compatMap is provided and non-empty.
|
|
1095
1761
|
* Returns the original results array when no compat data is available.
|
|
1096
1762
|
*/
|
|
1097
|
-
function applyCompatMap(results, compatMap) {
|
|
1763
|
+
function applyCompatMap(results, compatMap, locationRoot) {
|
|
1098
1764
|
if (compatMap !== undefined && compatMap.size > 0) {
|
|
1099
|
-
return mergeCompatIntoResults(results, compatMap);
|
|
1765
|
+
return mergeCompatIntoResults(results, compatMap, locationRoot);
|
|
1100
1766
|
}
|
|
1101
1767
|
return results;
|
|
1102
1768
|
}
|
|
@@ -1133,7 +1799,7 @@ function applyVerboseFilter(results, verbose) {
|
|
|
1133
1799
|
const NESTED_PATH_CARRIERS = ['linkedFiles'];
|
|
1134
1800
|
function calculateSummary(results, startTime, compatMap, verbose, root) {
|
|
1135
1801
|
const { entries, ...base } = buildBaseSummary(results, startTime);
|
|
1136
|
-
const withCompat = applyCompatMap(entries, compatMap);
|
|
1802
|
+
const withCompat = applyCompatMap(entries, compatMap, root);
|
|
1137
1803
|
return {
|
|
1138
1804
|
// Stated once, first, and the only absolute path in the document.
|
|
1139
1805
|
root,
|
|
@@ -1236,19 +1902,75 @@ function renderVerboseEvidence(results, root, logger) {
|
|
|
1236
1902
|
}
|
|
1237
1903
|
}
|
|
1238
1904
|
}
|
|
1905
|
+
/**
|
|
1906
|
+
* The stderr half of a run-level refusal, so the human channel says what the
|
|
1907
|
+
* document says. The document is derived in `buildBaseSummary`; this only
|
|
1908
|
+
* RENDERS it. Both stderr lanes call it first, because "Audit successful: 0
|
|
1909
|
+
* file(s) passed" over `status: error` is the exact disagreement the refusal
|
|
1910
|
+
* exists to end (invariant 6 of `run-integrity.ts`). The run ends on
|
|
1911
|
+
* `FINDINGS`, like every other `status: error` this command publishes.
|
|
1912
|
+
*
|
|
1913
|
+
* @returns Whether a refusal was rendered, so the caller skips its verdict line
|
|
1914
|
+
*/
|
|
1915
|
+
function logRunIntegrity(runIssues, logger) {
|
|
1916
|
+
if (runIssues === undefined || runIssues.length === 0)
|
|
1917
|
+
return false;
|
|
1918
|
+
logger.error('Audit is not a verdict: it audited 0 files');
|
|
1919
|
+
for (const issue of runIssues) {
|
|
1920
|
+
for (const line of formatIssueLines(issue, ' '))
|
|
1921
|
+
logger.error(line);
|
|
1922
|
+
}
|
|
1923
|
+
return true;
|
|
1924
|
+
}
|
|
1925
|
+
/**
|
|
1926
|
+
* The stderr line for the settings lane: how many conflicts it found and how
|
|
1927
|
+
* many paths it never compared, across every plugin in the document.
|
|
1928
|
+
*
|
|
1929
|
+
* 🚩 This read `f.compatibility?.settingsConflicts` — a field the renderer had
|
|
1930
|
+
* already moved out into the `settings:` block — so the count was 0 on every
|
|
1931
|
+
* run and the line never printed. It reads the rendered block now, the same
|
|
1932
|
+
* one the YAML carries, and it counts the unchecked half too: a run that
|
|
1933
|
+
* skipped a skill says so without `--debug`.
|
|
1934
|
+
*/
|
|
1935
|
+
function logSettingsTotals(files, logger) {
|
|
1936
|
+
let conflicts = 0;
|
|
1937
|
+
let unchecked = 0;
|
|
1938
|
+
// The compat lane's own unchecked paths — a plugin the analyzer could only
|
|
1939
|
+
// partly read is in the document under `compatibility.unchecked`, and this is
|
|
1940
|
+
// the one line that keeps it from being silent on the human channel while the
|
|
1941
|
+
// settings lane says its half. `analyzed: false` has no list: it is the
|
|
1942
|
+
// plugin-wide failure, and the `reason` line already says so.
|
|
1943
|
+
let compatUnchecked = 0;
|
|
1944
|
+
for (const { compatibility, settings } of files) {
|
|
1945
|
+
conflicts += settings?.conflicts.length ?? 0;
|
|
1946
|
+
unchecked += settings?.unchecked?.length ?? 0;
|
|
1947
|
+
if (compatibility !== undefined && 'unchecked' in compatibility)
|
|
1948
|
+
compatUnchecked += compatibility.unchecked.length;
|
|
1949
|
+
}
|
|
1950
|
+
if (conflicts > 0) {
|
|
1951
|
+
logger.error(`⚠ ${conflicts} settings conflict(s) found — see 'settings' section in YAML output`);
|
|
1952
|
+
}
|
|
1953
|
+
if (unchecked > 0) {
|
|
1954
|
+
logger.error(`⚠ ${unchecked} path(s) the settings check could not compare — see 'settings.unchecked' in YAML output`);
|
|
1955
|
+
}
|
|
1956
|
+
if (compatUnchecked > 0) {
|
|
1957
|
+
logger.error(`⚠ ${compatUnchecked} path(s) the compatibility analysis could not read — see 'compatibility.unchecked' in YAML output`);
|
|
1958
|
+
}
|
|
1959
|
+
}
|
|
1239
1960
|
function handleAuditResults(results, summary, logger, verbose) {
|
|
1240
1961
|
const { filesWithErrors: errorCount, filesWithWarnings: warningCount, filesPassed: successCount, } = summary.summary;
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1962
|
+
logSettingsTotals(summary.files ?? [], logger);
|
|
1963
|
+
// The exit code follows the document's `status`, as it does in every other
|
|
1964
|
+
// command of this CLI: `status: error` — an error-severity finding, or a run
|
|
1965
|
+
// that audited zero files — ends on `FINDINGS`. This used to be the one
|
|
1966
|
+
// command that exited 0 over `status: error` ("advisory"), and an adopter
|
|
1967
|
+
// wiring it into CI read "Audit failed" on stderr beside a green step.
|
|
1968
|
+
const refused = logRunIntegrity(summary.issues, logger);
|
|
1969
|
+
if (refused) {
|
|
1970
|
+
// Rendered above; there is no file verdict to print over zero files.
|
|
1971
|
+
}
|
|
1972
|
+
else if (errorCount > 0) {
|
|
1973
|
+
logger.error(`Audit found ${errorCount} file(s) with errors`);
|
|
1252
1974
|
logFindingsForStatus(results, 'error', summary.root, logger.error.bind(logger), verbose);
|
|
1253
1975
|
}
|
|
1254
1976
|
else if (warningCount > 0) {
|
|
@@ -1264,7 +1986,7 @@ function handleAuditResults(results, summary, logger, verbose) {
|
|
|
1264
1986
|
: `Audit successful: ${successCount} file(s) passed`);
|
|
1265
1987
|
}
|
|
1266
1988
|
renderAuditFooter(results, logger);
|
|
1267
|
-
process.exit(0);
|
|
1989
|
+
process.exit(refused || errorCount > 0 ? ExitCode.FINDINGS : ExitCode.OK);
|
|
1268
1990
|
}
|
|
1269
1991
|
/**
|
|
1270
1992
|
* Render the skill-quality checklist footer when audit results contain
|
|
@@ -1359,17 +2081,6 @@ function logFindingsForStatus(results, status, root, logFn, verbose) {
|
|
|
1359
2081
|
logFn(line);
|
|
1360
2082
|
}
|
|
1361
2083
|
}
|
|
1362
|
-
/**
|
|
1363
|
-
* Check whether a path should be excluded during directory scanning.
|
|
1364
|
-
* For directories, checks both bare path and path with trailing slash
|
|
1365
|
-
* so patterns like "dist/**" prune the directory itself.
|
|
1366
|
-
*/
|
|
1367
|
-
function isExcludedPath(isMatch, relativePath, isDirectory) {
|
|
1368
|
-
if (isDirectory) {
|
|
1369
|
-
return isMatch(relativePath) || isMatch(relativePath + '/');
|
|
1370
|
-
}
|
|
1371
|
-
return isMatch(relativePath);
|
|
1372
|
-
}
|
|
1373
2084
|
/**
|
|
1374
2085
|
* Does the SKILL.md at `skillMdPath` own its own presence-side crawl, or has an
|
|
1375
2086
|
* ancestor crawl already covered it?
|
|
@@ -1383,39 +2094,27 @@ function ownsOwnCrawl(scanCtx, skillMdPath) {
|
|
|
1383
2094
|
return scanCtx.crawledRoot === null || !isWithin(scanCtx.crawledRoot, safePath.resolve(skillMdPath));
|
|
1384
2095
|
}
|
|
1385
2096
|
/**
|
|
1386
|
-
*
|
|
1387
|
-
*
|
|
2097
|
+
* The directories whose presence-side crawl already owns everything beneath
|
|
2098
|
+
* them — see {@link ScanContext.crawledRoot} for why there are two owner kinds
|
|
2099
|
+
* and why the first owner wins.
|
|
2100
|
+
*
|
|
2101
|
+
* Kept as a list of roots rather than threaded down a recursion: the
|
|
2102
|
+
* population arrives flat and top-down (an ancestor's subjects before a
|
|
2103
|
+
* descendant's), so "who owns this path" is the first claimed root that
|
|
2104
|
+
* contains it, and a claim is only ever made where no ancestor holds one.
|
|
1388
2105
|
*/
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
* subdirectories it governs are siblings in one `readdir`, and their order is
|
|
1402
|
-
* the filesystem's to choose, so deciding this mid-loop would make the verdict
|
|
1403
|
-
* depend on it.
|
|
1404
|
-
*
|
|
1405
|
-
* Conditional on the crawl actually RUNNING ({@link crawlOwnsSubtree}), not on
|
|
1406
|
-
* the SKILL.md merely existing: a repo-source skill's crawl is skipped
|
|
1407
|
-
* entirely, so claiming its subtree would trade a double count for a missed
|
|
1408
|
-
* finding — and provenance is not monotone down a tree.
|
|
1409
|
-
*/
|
|
1410
|
-
async function contextForDescendants(dirPath, entries, scanCtx) {
|
|
1411
|
-
if (!entries.some((entry) => entry.isFile() && entry.name === 'SKILL.md'))
|
|
1412
|
-
return scanCtx;
|
|
1413
|
-
const skillMd = safePath.join(dirPath, 'SKILL.md');
|
|
1414
|
-
if (!ownsOwnCrawl(scanCtx, skillMd))
|
|
1415
|
-
return scanCtx;
|
|
1416
|
-
if (!await crawlOwnsSubtree(skillMd))
|
|
1417
|
-
return scanCtx;
|
|
1418
|
-
return claimSubtree(scanCtx, dirPath);
|
|
2106
|
+
class SubtreeClaims {
|
|
2107
|
+
roots = [];
|
|
2108
|
+
/** The claimed root containing `absolutePath`, or `null` while nothing does. */
|
|
2109
|
+
owner(absolutePath) {
|
|
2110
|
+
const resolved = safePath.resolve(absolutePath);
|
|
2111
|
+
return this.roots.find((root) => isWithin(root, resolved)) ?? null;
|
|
2112
|
+
}
|
|
2113
|
+
/** Claim everything below `dir`, unless an ancestor already did. First owner wins. */
|
|
2114
|
+
claim(dir) {
|
|
2115
|
+
if (this.owner(dir) === null)
|
|
2116
|
+
this.roots.push(safePath.resolve(dir));
|
|
2117
|
+
}
|
|
1419
2118
|
}
|
|
1420
2119
|
/**
|
|
1421
2120
|
* Handle file entry during directory scan
|
|
@@ -1433,25 +2132,41 @@ async function handleFileEntry(entry, fullPath, options, logger, scanCtx) {
|
|
|
1433
2132
|
// Config-aware: walk UP to the skill's nearest-ancestor config and apply
|
|
1434
2133
|
// ONLY that skill's declared packaging rules. Configs do not compose
|
|
1435
2134
|
// across VAT projects — audit does not merge rules across sibling configs.
|
|
1436
|
-
|
|
1437
|
-
|
|
2135
|
+
// Shared with the single-target lane, so one unloadable nested config
|
|
2136
|
+
// cannot abort this whole scan while that lane shrugs it off. The warn-once
|
|
2137
|
+
// ledger lives in `unloadableConfigResults` (module scope, cleared by
|
|
2138
|
+
// `resetAuditCaches`) rather than being threaded here, so BOTH lanes dedupe
|
|
2139
|
+
// against the same state — this one used to, the other minted a fresh set
|
|
2140
|
+
// per skill.
|
|
2141
|
+
const skillConfig = await resolveGoverningConfig(fullPath, logger, locationRoot);
|
|
1438
2142
|
if (skillConfig !== null) {
|
|
1439
2143
|
logger.debug(` Using config-aware validation for: ${fullPath}`);
|
|
1440
2144
|
// Thread the per-scan tracker into packaging validation so gitignore
|
|
1441
2145
|
// checks in the link-graph walk stay O(1).
|
|
1442
2146
|
// `projectSkills`: same project-wide test-input rule as the lane above,
|
|
1443
2147
|
// resolved through the same per-config-root memo.
|
|
1444
|
-
const projectSkills = await resolveProjectDeclaredEvalSuites(fullPath);
|
|
2148
|
+
const projectSkills = await resolveProjectDeclaredEvalSuites(fullPath, degradingDiscovery(findProjectRoot(safePath.resolve(fullPath, '..')) ?? locationRoot, logger, locationRoot));
|
|
2149
|
+
// `suiteProbe`: the RUN's, not this entry's — the directory walk reaches
|
|
2150
|
+
// this function once per SKILL.md it finds. See {@link runSuiteProbe}.
|
|
2151
|
+
const suiteProbe = suiteProbeForRun();
|
|
2152
|
+
// No registry is supplied on this lane, so `validateSkillForPackaging`
|
|
2153
|
+
// crawls one per project root through `crawlAndResolveRegistry` — under
|
|
2154
|
+
// audit's ruling, which is the ONLY reason one `chmod 000` sibling no
|
|
2155
|
+
// longer exits this whole run at 2 with zero findings. The root is the
|
|
2156
|
+
// one the validator itself derives (`findProjectRoot(dirname) ?? dirname`),
|
|
2157
|
+
// so the refuse sentence and the finding name the same crawl.
|
|
2158
|
+
const skillDir = safePath.resolve(fullPath, '..');
|
|
2159
|
+
const unreadable = degradingRegistry(findProjectRoot(skillDir) ?? skillDir, logger, locationRoot);
|
|
1445
2160
|
const sharedCtx = scanCtx.gitTracker === null
|
|
1446
|
-
? { locationRoot, projectSkills }
|
|
1447
|
-
: { gitTracker: scanCtx.gitTracker, locationRoot, projectSkills };
|
|
2161
|
+
? { locationRoot, projectSkills, suiteProbe, unreadable }
|
|
2162
|
+
: { gitTracker: scanCtx.gitTracker, locationRoot, projectSkills, suiteProbe, unreadable };
|
|
1448
2163
|
const packagingResult = await validateSkillForPackaging(fullPath, skillConfig, 'source', sharedCtx);
|
|
1449
2164
|
const configAware = packagingResultToValidationResult(fullPath, packagingResult, skillConfig.targets, locationRoot);
|
|
1450
2165
|
await appendDistributedTreeFindings(configAware, fullPath, locationRoot, ownsOwnCrawl(scanCtx, fullPath));
|
|
1451
2166
|
return configAware;
|
|
1452
2167
|
}
|
|
1453
|
-
// Wild mode: basic validation
|
|
1454
|
-
const validateOptions = { skillPath: fullPath, locationRoot };
|
|
2168
|
+
// Wild mode: basic validation. Severity is applied by `applySeverityFilter`.
|
|
2169
|
+
const validateOptions = { skillPath: fullPath, locationRoot, validation: {} };
|
|
1455
2170
|
if (options.warnUnreferencedFiles) {
|
|
1456
2171
|
validateOptions.checkUnreferencedFiles = true;
|
|
1457
2172
|
}
|
|
@@ -1465,79 +2180,15 @@ async function handleFileEntry(entry, fullPath, options, logger, scanCtx) {
|
|
|
1465
2180
|
return null;
|
|
1466
2181
|
}
|
|
1467
2182
|
/**
|
|
1468
|
-
*
|
|
1469
|
-
|
|
1470
|
-
async function handleDirectoryEntry(fullPath, recursive, options, logger, baseDir, scanCtx, nestedConfigLog) {
|
|
1471
|
-
const fs = await import('node:fs/promises');
|
|
1472
|
-
const { locationRoot } = scanCtx;
|
|
1473
|
-
const results = [];
|
|
1474
|
-
// Check if directory contains a plugin or marketplace
|
|
1475
|
-
const claudePluginDir = safePath.join(fullPath, '.claude-plugin');
|
|
1476
|
-
const hasClaudePlugin = await fs.access(claudePluginDir).then(() => true).catch(() => false);
|
|
1477
|
-
if (hasClaudePlugin) {
|
|
1478
|
-
logger.debug(`Validating resource directory: ${fullPath}`);
|
|
1479
|
-
const result = await validate(fullPath, { validatePlugin, locationRoot });
|
|
1480
|
-
const inv = await pluginInventoryAt(fullPath);
|
|
1481
|
-
appendInventoryParseErrors(result, inv, locationRoot);
|
|
1482
|
-
results.push(result);
|
|
1483
|
-
}
|
|
1484
|
-
// Recurse into subdirectories (both plugin/marketplace dirs and regular dirs).
|
|
1485
|
-
// Below a plugin, `validatePlugin` has already crawled the whole subtree for
|
|
1486
|
-
// agent-instruction files, so every crawl below must stand down or one file
|
|
1487
|
-
// is reported twice. See {@link ScanContext.crawledRoot} — first owner wins,
|
|
1488
|
-
// and it never clears on the way down.
|
|
1489
|
-
const childCtx = claimSubtree(scanCtx, hasClaudePlugin ? fullPath : null);
|
|
1490
|
-
if (recursive) {
|
|
1491
|
-
const subResults = await scanDirectory(fullPath, recursive, options, logger, childCtx, baseDir, nestedConfigLog);
|
|
1492
|
-
results.push(...subResults);
|
|
1493
|
-
}
|
|
1494
|
-
return results;
|
|
1495
|
-
}
|
|
1496
|
-
/**
|
|
1497
|
-
* Compile the governing project's `resources.exclude` for a scan.
|
|
1498
|
-
*
|
|
1499
|
-
* The config is found by walking UP from the scan directory ({@link findProjectRoot}),
|
|
1500
|
-
* not by looking in it. Looking only in the scan directory is how a path argument
|
|
1501
|
-
* silently voided every exclude the project had declared: `vat audit
|
|
1502
|
-
* packages/x/resources/skills/` found no config there, so a package that excludes
|
|
1503
|
-
* its deliberately-broken eval fixtures had them audited as production skills the
|
|
1504
|
-
* moment anyone named a subdirectory. Commit 8a466b0c fixed the same defect for
|
|
1505
|
-
* `vat resources validate|scan` and `vat rag index`; this is the lane it missed.
|
|
1506
|
-
* The rule is the same one that commit established: a path argument says WHICH
|
|
1507
|
-
* tree to audit, and `exclude` applies either way.
|
|
1508
|
-
*
|
|
1509
|
-
* With one deliberate exception, mirroring the gitignore rule in
|
|
1510
|
-
* {@link resolveScanContext}: when the operator points the scan AT an excluded
|
|
1511
|
-
* tree, their explicit intent wins and the excludes are dropped for that run.
|
|
1512
|
-
* Otherwise naming an excluded directory would report `filesScanned: 0,
|
|
1513
|
-
* status: success` — a green run that scanned nothing.
|
|
2183
|
+
* Validate a directory the population found holding `.claude-plugin/` — a
|
|
2184
|
+
* plugin or a marketplace, whichever the unified validator decides.
|
|
1514
2185
|
*/
|
|
1515
|
-
function
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
patterns = loadConfig(projectRoot)?.resources?.exclude ?? [];
|
|
1522
|
-
}
|
|
1523
|
-
catch (err) {
|
|
1524
|
-
// Audit is a bulk linter over trees it does not own; a broken governing
|
|
1525
|
-
// config must not abort the scan. Say so rather than dropping it silently.
|
|
1526
|
-
logger.debug(`Ignoring unreadable config at ${projectRoot}: ${String(err)}`);
|
|
1527
|
-
return null;
|
|
1528
|
-
}
|
|
1529
|
-
if (patterns.length === 0)
|
|
1530
|
-
return null;
|
|
1531
|
-
// dot:true so excludes like `**/.cache/*` match through dotfile dirs;
|
|
1532
|
-
// without it the exclude silently never fires.
|
|
1533
|
-
const isMatch = picomatch([...patterns], { dot: true });
|
|
1534
|
-
const scanRelToProject = safePath.relative(projectRoot, safePath.resolve(scanDir)).replaceAll('\\', '/');
|
|
1535
|
-
if (scanRelToProject !== '' && isExcludedPath(isMatch, scanRelToProject, true)) {
|
|
1536
|
-
logger.debug(`Scan root ${scanRelToProject} is excluded by the config at ${projectRoot}; ` +
|
|
1537
|
-
'auditing it anyway because it was named explicitly');
|
|
1538
|
-
return null;
|
|
1539
|
-
}
|
|
1540
|
-
return { isMatch, base: projectRoot, label: 'excluded by config' };
|
|
2186
|
+
async function validatePluginDirectory(dir, logger, locationRoot) {
|
|
2187
|
+
logger.debug(`Validating resource directory: ${dir}`);
|
|
2188
|
+
const result = await validate(dir, { validatePlugin, locationRoot });
|
|
2189
|
+
const inv = await pluginInventoryAt(dir);
|
|
2190
|
+
appendInventoryParseErrors(result, inv, locationRoot);
|
|
2191
|
+
return result;
|
|
1541
2192
|
}
|
|
1542
2193
|
/**
|
|
1543
2194
|
* Cache of (projectRoot → registry for the *inventory* link walk).
|
|
@@ -1590,7 +2241,12 @@ async function getOrCreateInventoryRegistry(projectRoot) {
|
|
|
1590
2241
|
*/
|
|
1591
2242
|
async function pluginInventoryAt(dir) {
|
|
1592
2243
|
const projectRoot = findProjectRoot(dir);
|
|
1593
|
-
return extractClaudePluginInventory(dir,
|
|
2244
|
+
return extractClaudePluginInventory(dir, {
|
|
2245
|
+
...(projectRoot === null
|
|
2246
|
+
? {}
|
|
2247
|
+
: { sharedRegistry: () => getOrCreateInventoryRegistry(projectRoot) }),
|
|
2248
|
+
gitTrackerSource: gitTrackerForProjectRoot,
|
|
2249
|
+
});
|
|
1594
2250
|
}
|
|
1595
2251
|
/**
|
|
1596
2252
|
* Reset all module-level audit caches. Must be called at the start of every
|
|
@@ -1602,6 +2258,8 @@ export function resetAuditCaches() {
|
|
|
1602
2258
|
resetGitTrackerCache();
|
|
1603
2259
|
resetPackagingRegistryCache();
|
|
1604
2260
|
inventoryRegistryCache.clear();
|
|
2261
|
+
unloadableConfigResults.clear();
|
|
2262
|
+
runSuiteProbe = undefined;
|
|
1605
2263
|
resetProjectRootCaches();
|
|
1606
2264
|
resetLoadedConfigCache();
|
|
1607
2265
|
resetSkillDiscoveryCache();
|
|
@@ -1631,107 +2289,150 @@ async function resolveScanContext(dirPath, locationRoot, logger) {
|
|
|
1631
2289
|
return { gitRoot: detectedRoot, gitTracker: tracker, locationRoot, projectExcludes, crawledRoot: null };
|
|
1632
2290
|
}
|
|
1633
2291
|
/**
|
|
1634
|
-
*
|
|
1635
|
-
*
|
|
1636
|
-
*
|
|
2292
|
+
* The one finding a path the walk could not read produces — a directory it could
|
|
2293
|
+
* not enter, or a file it could not open.
|
|
2294
|
+
*
|
|
2295
|
+
* A synthetic result rather than an issue appended to some neighbour: nothing
|
|
2296
|
+
* about the unreadable path was validated, so it has no host result to hang
|
|
2297
|
+
* off, and attaching it to a sibling would attribute the condition to a tree that
|
|
2298
|
+
* scanned fine. Typed `unknown` for the same reason — the whole point is that its
|
|
2299
|
+
* contents were never seen, so any more specific type would be a claim the scan
|
|
2300
|
+
* cannot support.
|
|
2301
|
+
*
|
|
2302
|
+
* The OS message is carried through verbatim (it names the errno and the syscall,
|
|
2303
|
+
* which is what distinguishes a permissions problem from a vanished mount), but it
|
|
2304
|
+
* is no longer the ONLY thing the run says — the code, the location and the fix
|
|
2305
|
+
* come from the registry.
|
|
2306
|
+
*
|
|
2307
|
+
* This result is a `files[]` row but NOT a scanned file: `countFilesByStatus`
|
|
2308
|
+
* recognises it ({@link isUnreadablePathResult}) and reports it under
|
|
2309
|
+
* `summary.pathsUnreadable`, outside `filesScanned` and the status counts. A
|
|
2310
|
+
* root the OS refused therefore audits zero files and ends on the zero-files
|
|
2311
|
+
* refusal, instead of `filesScanned: 1` over a path nothing read.
|
|
1637
2312
|
*/
|
|
1638
|
-
function
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
2313
|
+
function unreadablePathResult(dirPath, error, locationRoot) {
|
|
2314
|
+
// `issueLocation` is `path.relative`, so it answers `''` when the subject IS the
|
|
2315
|
+
// anchor — and for this finding that is not exotic, it is `vat audit <path>`
|
|
2316
|
+
// where the named path is the unreadable one. `''` reached the report as
|
|
2317
|
+
// `location: ""` and rendered the detail as a bare `": EACCES …"`. `.` is that
|
|
2318
|
+
// same path spelled as something a reader can act on. The identical fix, for the
|
|
2319
|
+
// identical reason, already exists on the distributed-tree finding — see
|
|
2320
|
+
// `anchoredTreeLocation` in ./audit/distributed-tree.ts.
|
|
2321
|
+
const location = issueLocation(dirPath, locationRoot) || '.';
|
|
2322
|
+
// The OS names the path it refused absolutely; the report speaks scan-root
|
|
2323
|
+
// coordinates everywhere else, and an absolute path here is the build host's
|
|
2324
|
+
// `$HOME` in every CI log.
|
|
2325
|
+
const reason = withRelativePath(error instanceof Error ? error.message : String(error), dirPath, locationRoot);
|
|
2326
|
+
const issues = [
|
|
2327
|
+
materializeIssue('SCAN_PATH_UNREADABLE', {
|
|
2328
|
+
location,
|
|
2329
|
+
detail: `${location}: ${reason}`,
|
|
2330
|
+
}),
|
|
2331
|
+
];
|
|
2332
|
+
const issueCounts = countBySeverity(issues);
|
|
2333
|
+
return {
|
|
2334
|
+
path: dirPath,
|
|
2335
|
+
type: 'unknown',
|
|
2336
|
+
status: calculateValidationStatus(issues),
|
|
2337
|
+
summary: formatCountsSummary(issueCounts),
|
|
2338
|
+
issues,
|
|
2339
|
+
issueCounts,
|
|
2340
|
+
};
|
|
1647
2341
|
}
|
|
1648
2342
|
/**
|
|
1649
|
-
*
|
|
1650
|
-
*
|
|
2343
|
+
* Validate ONE subject the population found, degrading to a finding if the
|
|
2344
|
+
* filesystem refuses it.
|
|
2345
|
+
*
|
|
2346
|
+
* Guarded per SUBJECT, not just per listing: an unreadable FILE (a root-owned
|
|
2347
|
+
* `SKILL.md`, a quarantined bundle) used to throw out of `validateSkill` and
|
|
2348
|
+
* abort the whole run with `status: error` and zero findings. Under
|
|
2349
|
+
* `~/.claude/plugins` — the flagship `vat audit --user` target, populated by sudo
|
|
2350
|
+
* installs and macOS quarantine — a root-owned FILE is at least as likely as a
|
|
2351
|
+
* root-owned directory.
|
|
2352
|
+
*
|
|
2353
|
+
* Ownership is decided here and only here, in population order: a plugin
|
|
2354
|
+
* directory claims its subtree unconditionally; a skill claims its own only
|
|
2355
|
+
* when nothing above it already has AND its crawl actually runs
|
|
2356
|
+
* ({@link crawlOwnsSubtree}) — a repo-source skill's crawl is skipped, so
|
|
2357
|
+
* claiming its subtree would trade a double count for a missed finding.
|
|
1651
2358
|
*/
|
|
1652
|
-
function
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
return `gitignored: ${entry.name}`;
|
|
1660
|
-
}
|
|
1661
|
-
// Each matcher answers against ITS OWN base — see {@link ExcludeMatcher}.
|
|
1662
|
-
for (const matcher of excludeMatchers) {
|
|
1663
|
-
const relativePath = safePath.relative(matcher.base, fullPath).replaceAll('\\', '/');
|
|
1664
|
-
if (isExcludedPath(matcher.isMatch, relativePath, entry.isDirectory())) {
|
|
1665
|
-
return `${matcher.label}: ${relativePath}`;
|
|
2359
|
+
async function validateScanSubject(subject, options, logger, scanCtx, claims) {
|
|
2360
|
+
const { locationRoot } = scanCtx;
|
|
2361
|
+
try {
|
|
2362
|
+
if (subject.kind === 'plugin') {
|
|
2363
|
+
const result = await validatePluginDirectory(subject.dir, logger, locationRoot);
|
|
2364
|
+
claims.claim(subject.dir);
|
|
2365
|
+
return [result];
|
|
1666
2366
|
}
|
|
2367
|
+
const subjectCtx = { ...scanCtx, crawledRoot: claims.owner(subject.path) };
|
|
2368
|
+
const result = await handleFileEntry({ name: basename(subject.path) }, subject.path, options, logger, subjectCtx);
|
|
2369
|
+
if (subject.kind === 'skill' && ownsOwnCrawl(subjectCtx, subject.path) && await crawlOwnsSubtree(subject.path)) {
|
|
2370
|
+
claims.claim(subject.dir);
|
|
2371
|
+
}
|
|
2372
|
+
return result === null ? [] : [result];
|
|
2373
|
+
}
|
|
2374
|
+
catch (error) {
|
|
2375
|
+
// ONLY filesystem-access errors degrade. Anything else is rethrown, because
|
|
2376
|
+
// turning a genuine VAT bug into a `warning` about the file it happened on is
|
|
2377
|
+
// the same "detector silently disables itself" shape this guard exists to
|
|
2378
|
+
// prevent — it would make the tool quietest exactly when it is most wrong.
|
|
2379
|
+
if (!isFilesystemAccessError(error))
|
|
2380
|
+
throw error;
|
|
2381
|
+
const path = subject.kind === 'plugin' ? subject.dir : subject.path;
|
|
2382
|
+
logger.debug(`Unreadable entry: ${path}`);
|
|
2383
|
+
return [unreadablePathResult(path, error, locationRoot)];
|
|
1667
2384
|
}
|
|
1668
|
-
return null;
|
|
1669
2385
|
}
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
2386
|
+
/**
|
|
2387
|
+
* The remedy a refused listing names: the operator's own knob for dropping a
|
|
2388
|
+
* directory from the audit on purpose.
|
|
2389
|
+
*/
|
|
2390
|
+
function scanRefusalRemedy() {
|
|
2391
|
+
return 'To leave it out deliberately, exclude it with --exclude <pattern>.';
|
|
2392
|
+
}
|
|
2393
|
+
/**
|
|
2394
|
+
* Audit a directory: every plugin, skill and registry file the `crawl` lane
|
|
2395
|
+
* finds beneath it, plus one finding per directory the lane could not list.
|
|
2396
|
+
*
|
|
2397
|
+
* The enumeration is `enumerateAuditPopulation`'s — this function owns no
|
|
2398
|
+
* walk of its own. What it decides is what the audit MAKES of the population:
|
|
2399
|
+
* validation per subject, subtree ownership across subjects (see
|
|
2400
|
+
* {@link validateScanSubject}), the nested-config breadcrumb, and the refusal
|
|
2401
|
+
* findings. A refused directory is a finding, not a thrown error: `vat audit`
|
|
2402
|
+
* is a bulk linter over trees it does not own, and one root-owned directory
|
|
2403
|
+
* under `~/.claude/plugins` used to abort the flagship `vat audit --user` run
|
|
2404
|
+
* outright, losing every finding already collected. The scope of the loss is
|
|
2405
|
+
* the subtree that refused; every readable sibling is still validated.
|
|
2406
|
+
*/
|
|
2407
|
+
async function scanDirectory(dirPath, recursive, options, logger, scanCtx) {
|
|
2408
|
+
// Warm findProjectRoot's walk-up cache from the scan root: every per-skill
|
|
2409
|
+
// walk-up below lands on a cached ancestor instead of re-walking to `/`.
|
|
1683
2410
|
findProjectRoot(dirPath);
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
//
|
|
1696
|
-
//
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
excludeMatchers.push(resolvedScanCtx.projectExcludes);
|
|
1710
|
-
}
|
|
1711
|
-
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
|
1712
|
-
const descendCtx = await contextForDescendants(dirPath, entries, resolvedScanCtx);
|
|
1713
|
-
// Build full paths and batch-check gitignore status
|
|
1714
|
-
const entryPaths = entries.map(e => safePath.join(dirPath, e.name));
|
|
1715
|
-
const gitIgnoredMap = buildGitIgnoreMap(entryPaths, resolvedScanCtx.gitTracker, options.includeArtifacts ?? false);
|
|
1716
|
-
for (const entry of entries) {
|
|
1717
|
-
const fullPath = safePath.join(dirPath, entry.name);
|
|
1718
|
-
const skipReason = getSkipReason(entry, fullPath, gitIgnoredMap, excludeMatchers);
|
|
1719
|
-
if (skipReason !== null) {
|
|
1720
|
-
logger.debug(`Excluding path: ${skipReason}`);
|
|
1721
|
-
continue;
|
|
1722
|
-
}
|
|
1723
|
-
if (entry.isFile()) {
|
|
1724
|
-
const result = await handleFileEntry(entry, fullPath, options, logger, resolvedScanCtx);
|
|
1725
|
-
if (result !== null) {
|
|
1726
|
-
results.push(result);
|
|
1727
|
-
}
|
|
1728
|
-
}
|
|
1729
|
-
else if (entry.isDirectory()) {
|
|
1730
|
-
// `descendCtx`, not `resolvedScanCtx`: this level's own skill (if any)
|
|
1731
|
-
// owns everything below, including this subdirectory.
|
|
1732
|
-
const dirResults = await handleDirectoryEntry(fullPath, recursive, options, logger, resolvedBaseDir, descendCtx, resolvedNestedLog);
|
|
1733
|
-
results.push(...dirResults);
|
|
1734
|
-
}
|
|
2411
|
+
const population = await enumerateAuditPopulation({
|
|
2412
|
+
scanDir: dirPath,
|
|
2413
|
+
recursive,
|
|
2414
|
+
// No tracker means no git root, or a scan root that is itself gitignored
|
|
2415
|
+
// (the operator's explicit intent wins); either way ignored files are in.
|
|
2416
|
+
respectGitignore: scanCtx.gitTracker !== null && !(options.includeArtifacts ?? false),
|
|
2417
|
+
userExcludes: options.exclude ?? [],
|
|
2418
|
+
projectExcludes: scanCtx.projectExcludes,
|
|
2419
|
+
});
|
|
2420
|
+
// A one-time breadcrumb per nested `vibe-agent-toolkit.config.yaml` that is
|
|
2421
|
+
// NOT at the scan root. Configs do not compose across VAT projects; the
|
|
2422
|
+
// message reminds operators that only per-skill packaging rules from this
|
|
2423
|
+
// config still apply.
|
|
2424
|
+
for (const nestedConfigPath of population.nestedConfigs) {
|
|
2425
|
+
const rel = toForwardSlash(safePath.relative(dirPath, nestedConfigPath));
|
|
2426
|
+
logger.info(`Nested ${VAT_CONFIG_FILENAME} detected at ${rel} — configs do not compose across VAT projects. Per-skill packaging rules from this config still apply to skills declared in it.`);
|
|
2427
|
+
}
|
|
2428
|
+
const results = population.refusals.map((refusal) => {
|
|
2429
|
+
logger.debug(`Unreadable directory: ${refusal.directory}`);
|
|
2430
|
+
const err = new DirectoryListingRefusedError(refusal, { root: dirPath, remedy: scanRefusalRemedy() });
|
|
2431
|
+
return unreadablePathResult(refusal.directory, err, scanCtx.locationRoot);
|
|
2432
|
+
});
|
|
2433
|
+
const claims = new SubtreeClaims();
|
|
2434
|
+
for (const subject of population.subjects) {
|
|
2435
|
+
results.push(...await validateScanSubject(subject, options, logger, scanCtx, claims));
|
|
1735
2436
|
}
|
|
1736
2437
|
return results;
|
|
1737
2438
|
}
|
|
@@ -1760,13 +2461,34 @@ function calculateOverallStatus(results) {
|
|
|
1760
2461
|
function countResultsWithFindings(results) {
|
|
1761
2462
|
return results.filter((r) => r.issues.length > 0).length;
|
|
1762
2463
|
}
|
|
1763
|
-
/**
|
|
2464
|
+
/**
|
|
2465
|
+
* Whether a `files[]` row is a refused path rather than a scanned file: the
|
|
2466
|
+
* synthetic result {@link unreadablePathResult} builds, carrying nothing but
|
|
2467
|
+
* `SCAN_PATH_UNREADABLE`. Keyed on the code, not on `type: 'unknown'` alone —
|
|
2468
|
+
* an unrecognised file is also `unknown`, and it WAS examined.
|
|
2469
|
+
*/
|
|
2470
|
+
function isUnreadablePathResult(result) {
|
|
2471
|
+
return result.type === 'unknown'
|
|
2472
|
+
&& result.issues.length > 0
|
|
2473
|
+
&& result.issues.every((issue) => issue.code === 'SCAN_PATH_UNREADABLE');
|
|
2474
|
+
}
|
|
2475
|
+
/**
|
|
2476
|
+
* Count files by their own status.
|
|
2477
|
+
*
|
|
2478
|
+
* A refused path is NOT a scanned file. It used to be counted as one, which
|
|
2479
|
+
* made the zero-files refusal unreachable for exactly the tree it exists for:
|
|
2480
|
+
* `chmod 000` on the whole root gave `filesScanned: 1, filesPassed: 0`,
|
|
2481
|
+
* `status: warning`, exit 0 — nothing read, gate green. Kept out of every
|
|
2482
|
+
* status count too, or `filesWithWarnings` would exceed `filesScanned`.
|
|
2483
|
+
*/
|
|
1764
2484
|
function countFilesByStatus(results) {
|
|
2485
|
+
const scanned = results.filter((r) => !isUnreadablePathResult(r));
|
|
1765
2486
|
return {
|
|
1766
|
-
filesScanned:
|
|
1767
|
-
filesPassed:
|
|
1768
|
-
filesWithWarnings:
|
|
1769
|
-
filesWithErrors:
|
|
2487
|
+
filesScanned: scanned.length,
|
|
2488
|
+
filesPassed: scanned.filter((r) => r.status === 'success').length,
|
|
2489
|
+
filesWithWarnings: scanned.filter((r) => r.status === 'warning').length,
|
|
2490
|
+
filesWithErrors: scanned.filter((r) => r.status === 'error').length,
|
|
2491
|
+
pathsUnreadable: results.length - scanned.length,
|
|
1770
2492
|
};
|
|
1771
2493
|
}
|
|
1772
2494
|
/**
|
|
@@ -1794,6 +2516,24 @@ function countFilesByStatus(results) {
|
|
|
1794
2516
|
* also present in its owner's `issues`, so the nested list is a VIEW of records
|
|
1795
2517
|
* already counted, not extra records. `audit-report-coherence.integration.test.ts`
|
|
1796
2518
|
* pins that subset relation, because counting once is only correct while it holds.
|
|
2519
|
+
*
|
|
2520
|
+
* 🚨 **Zero files audited is an ERROR, not "an empty tree audits cleanly".**
|
|
2521
|
+
* `calculateOverallStatus([])` is `success`, so an existing directory holding
|
|
2522
|
+
* nothing auditable — moved plugins, a wrong subdirectory, an excluded tree, a
|
|
2523
|
+
* tree of files no lane recognises — published `status: success` beside
|
|
2524
|
+
* `filesScanned: 0`, and this command's own documentation tells CI to gate on
|
|
2525
|
+
* that `status`. Derived HERE, in the one builder both the directory lane
|
|
2526
|
+
* (`calculateSummary`) and the `--user` lane (`calculateHierarchicalSummary`)
|
|
2527
|
+
* pass through, so neither can publish a clean status over a zero denominator.
|
|
2528
|
+
* Through the shared mechanism in `run-integrity.ts`: one non-overridable
|
|
2529
|
+
* `RESOURCE_CHECK_BROKEN` at `error`, published under a top-level `issues` —
|
|
2530
|
+
* the claim is about the RUN, so it is not a `files[]` row and does not count
|
|
2531
|
+
* toward `filesScanned` — and counted in the header `issueCounts`, so the
|
|
2532
|
+
* identity becomes `issueCounts === Σ files[].issueCounts + Σ issues`.
|
|
2533
|
+
*
|
|
2534
|
+
* The exit code is NOT touched by this: `status` describes the findings and the
|
|
2535
|
+
* exit code describes whether the run completed, and this run completed. See
|
|
2536
|
+
* {@link createAuditCommand}.
|
|
1797
2537
|
*/
|
|
1798
2538
|
function buildBaseSummary(results, startTime) {
|
|
1799
2539
|
const issueCounts = { errors: 0, warnings: 0, info: 0 };
|
|
@@ -1804,11 +2544,24 @@ function buildBaseSummary(results, startTime) {
|
|
|
1804
2544
|
issueCounts.info += counts.info;
|
|
1805
2545
|
return { ...result, issueCounts: counts };
|
|
1806
2546
|
});
|
|
2547
|
+
const summary = countFilesByStatus(entries);
|
|
2548
|
+
// The denominator is files READ: a tree whose every path was refused audited
|
|
2549
|
+
// nothing, and says so through the same refusal as an empty tree.
|
|
2550
|
+
const runIssues = nothingCheckedFinding(summary.filesScanned, entries.flatMap((entry) => entry.issues), () => 'The audit ran over 0 files, so this report is not a verdict: a tree with nothing'
|
|
2551
|
+
+ ' to audit produces the same counts as a clean one. The path resolved to a tree'
|
|
2552
|
+
+ ' holding no auditable file — usually a wrong subdirectory, plugins or skills that'
|
|
2553
|
+
+ ' have moved, an excluded tree, files no audit lane recognises, or `--no-recursive`'
|
|
2554
|
+
+ ' over a tree whose resources sit deeper. Point the command at the plugin,'
|
|
2555
|
+
+ ' marketplace, registry or SKILL.md tree it should read.');
|
|
1807
2556
|
return {
|
|
1808
2557
|
entries,
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
2558
|
+
// Every run-integrity finding is `error` by construction (invariant 2 of
|
|
2559
|
+
// `run-integrity.ts`), so a non-empty refusal IS the status; otherwise the
|
|
2560
|
+
// files decide it, as before.
|
|
2561
|
+
status: runIssues.length > 0 ? 'error' : calculateOverallStatus(entries),
|
|
2562
|
+
summary,
|
|
2563
|
+
issueCounts: sumSeverityCounts([issueCounts, countBySeverity(runIssues)]),
|
|
2564
|
+
...(runIssues.length === 0 ? {} : { issues: [...runIssues] }),
|
|
1812
2565
|
duration: `${Date.now() - startTime}ms`,
|
|
1813
2566
|
};
|
|
1814
2567
|
}
|
|
@@ -1817,7 +2570,7 @@ function buildBaseSummary(results, startTime) {
|
|
|
1817
2570
|
*/
|
|
1818
2571
|
function calculateHierarchicalSummary(results, hierarchical, startTime, compatMap, verbose, root) {
|
|
1819
2572
|
const { entries, ...base } = buildBaseSummary(results, startTime);
|
|
1820
|
-
const withCompat = applyCompatMap(entries, compatMap);
|
|
2573
|
+
const withCompat = applyCompatMap(entries, compatMap, root);
|
|
1821
2574
|
return {
|
|
1822
2575
|
root,
|
|
1823
2576
|
...base,
|
|
@@ -1835,13 +2588,16 @@ function calculateHierarchicalSummary(results, hierarchical, startTime, compatMa
|
|
|
1835
2588
|
/**
|
|
1836
2589
|
* Log hierarchical summary to stderr
|
|
1837
2590
|
*/
|
|
1838
|
-
function logHierarchicalSummary(results, logger) {
|
|
2591
|
+
function logHierarchicalSummary(results, runIssues, logger) {
|
|
1839
2592
|
const status = calculateOverallStatus(results);
|
|
1840
2593
|
const skillsWithIssues = countResultsWithFindings(results);
|
|
1841
2594
|
const totalSkills = results.length;
|
|
1842
|
-
//
|
|
1843
|
-
|
|
1844
|
-
if (
|
|
2595
|
+
// Same rule as the per-file lane: the exit code follows `status`.
|
|
2596
|
+
const refused = logRunIntegrity(runIssues, logger);
|
|
2597
|
+
if (refused) {
|
|
2598
|
+
// Rendered above; there is no skill verdict to print over zero skills.
|
|
2599
|
+
}
|
|
2600
|
+
else if (status === 'error') {
|
|
1845
2601
|
const errorCount = results.filter((r) => r.status === 'error').length;
|
|
1846
2602
|
logger.error(`Audit found ${errorCount} skill(s) with errors (${totalSkills} scanned, ${skillsWithIssues} with issues)`);
|
|
1847
2603
|
}
|
|
@@ -1858,6 +2614,6 @@ function logHierarchicalSummary(results, logger) {
|
|
|
1858
2614
|
logger.info(`Audit successful: ${totalSkills} skill(s) passed`);
|
|
1859
2615
|
}
|
|
1860
2616
|
renderAuditFooter(results, logger);
|
|
1861
|
-
process.exit(
|
|
2617
|
+
process.exit(refused || status === 'error' ? ExitCode.FINDINGS : ExitCode.OK);
|
|
1862
2618
|
}
|
|
1863
2619
|
//# sourceMappingURL=audit.js.map
|