@vibe-agent-toolkit/cli 0.1.41-rc.6 → 0.1.41-rc.8
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 -3
- package/dist/bin/vat +25 -2
- package/dist/bin/vat.js +25 -2
- package/dist/bin/vat.js.map +1 -1
- package/dist/bin.js +12 -1
- package/dist/bin.js.map +1 -1
- package/dist/commands/agent/list.d.ts +11 -0
- package/dist/commands/agent/list.d.ts.map +1 -1
- package/dist/commands/agent/list.js +30 -11
- package/dist/commands/agent/list.js.map +1 -1
- package/dist/commands/audit/hierarchical-output.d.ts +6 -2
- package/dist/commands/audit/hierarchical-output.d.ts.map +1 -1
- package/dist/commands/audit/hierarchical-output.js +93 -50
- package/dist/commands/audit/hierarchical-output.js.map +1 -1
- package/dist/commands/audit-settings.d.ts +35 -0
- package/dist/commands/audit-settings.d.ts.map +1 -1
- package/dist/commands/audit-settings.js +121 -51
- package/dist/commands/audit-settings.js.map +1 -1
- package/dist/commands/audit.d.ts +97 -2
- package/dist/commands/audit.d.ts.map +1 -1
- package/dist/commands/audit.js +505 -231
- package/dist/commands/audit.js.map +1 -1
- package/dist/commands/build.d.ts +14 -0
- package/dist/commands/build.d.ts.map +1 -1
- package/dist/commands/build.js +126 -32
- package/dist/commands/build.js.map +1 -1
- package/dist/commands/claude/marketplace/license-utils.d.ts +39 -2
- package/dist/commands/claude/marketplace/license-utils.d.ts.map +1 -1
- package/dist/commands/claude/marketplace/license-utils.js +114 -68
- package/dist/commands/claude/marketplace/license-utils.js.map +1 -1
- package/dist/commands/claude/marketplace/publish.js +5 -4
- package/dist/commands/claude/marketplace/publish.js.map +1 -1
- package/dist/commands/claude/marketplace/validate.d.ts +97 -0
- package/dist/commands/claude/marketplace/validate.d.ts.map +1 -1
- package/dist/commands/claude/marketplace/validate.js +157 -49
- package/dist/commands/claude/marketplace/validate.js.map +1 -1
- package/dist/commands/claude/org/skills.d.ts +11 -3
- package/dist/commands/claude/org/skills.d.ts.map +1 -1
- package/dist/commands/claude/org/skills.js +70 -20
- package/dist/commands/claude/org/skills.js.map +1 -1
- package/dist/commands/claude/org/stubs.d.ts +9 -1
- package/dist/commands/claude/org/stubs.d.ts.map +1 -1
- package/dist/commands/claude/org/stubs.js +11 -3
- package/dist/commands/claude/org/stubs.js.map +1 -1
- package/dist/commands/claude/plugin/build.d.ts +116 -0
- package/dist/commands/claude/plugin/build.d.ts.map +1 -1
- package/dist/commands/claude/plugin/build.js +173 -40
- package/dist/commands/claude/plugin/build.js.map +1 -1
- package/dist/commands/claude/plugin/marketplace-json.d.ts +36 -0
- package/dist/commands/claude/plugin/marketplace-json.d.ts.map +1 -0
- package/dist/commands/claude/plugin/marketplace-json.js +34 -0
- package/dist/commands/claude/plugin/marketplace-json.js.map +1 -0
- package/dist/commands/claude/plugin/plugin-json-merge.d.ts +30 -1
- package/dist/commands/claude/plugin/plugin-json-merge.d.ts.map +1 -1
- package/dist/commands/claude/plugin/plugin-json-merge.js +107 -14
- package/dist/commands/claude/plugin/plugin-json-merge.js.map +1 -1
- package/dist/commands/claude/plugin/tree-copy.d.ts.map +1 -1
- package/dist/commands/claude/plugin/tree-copy.js +0 -1
- package/dist/commands/claude/plugin/tree-copy.js.map +1 -1
- package/dist/commands/corpus/report.d.ts +22 -4
- package/dist/commands/corpus/report.d.ts.map +1 -1
- package/dist/commands/corpus/report.js +1 -0
- package/dist/commands/corpus/report.js.map +1 -1
- package/dist/commands/corpus/runner.d.ts +16 -1
- package/dist/commands/corpus/runner.d.ts.map +1 -1
- package/dist/commands/corpus/runner.js +55 -48
- package/dist/commands/corpus/runner.js.map +1 -1
- package/dist/commands/doctor.d.ts +57 -8
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +142 -67
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/inventory.d.ts +2 -0
- package/dist/commands/inventory.d.ts.map +1 -1
- package/dist/commands/inventory.js +45 -4
- package/dist/commands/inventory.js.map +1 -1
- package/dist/commands/phase-utils.d.ts +240 -11
- package/dist/commands/phase-utils.d.ts.map +1 -1
- package/dist/commands/phase-utils.js +352 -15
- package/dist/commands/phase-utils.js.map +1 -1
- package/dist/commands/rag/query-command.d.ts +40 -0
- package/dist/commands/rag/query-command.d.ts.map +1 -1
- package/dist/commands/rag/query-command.js +47 -20
- package/dist/commands/rag/query-command.js.map +1 -1
- package/dist/commands/resources/index.d.ts.map +1 -1
- package/dist/commands/resources/index.js +38 -13
- package/dist/commands/resources/index.js.map +1 -1
- package/dist/commands/resources/scan.d.ts +37 -0
- package/dist/commands/resources/scan.d.ts.map +1 -1
- package/dist/commands/resources/scan.js +47 -32
- package/dist/commands/resources/scan.js.map +1 -1
- package/dist/commands/resources/validate.d.ts +136 -1
- package/dist/commands/resources/validate.d.ts.map +1 -1
- package/dist/commands/resources/validate.js +121 -68
- package/dist/commands/resources/validate.js.map +1 -1
- package/dist/commands/skill/review.d.ts.map +1 -1
- package/dist/commands/skill/review.js +40 -23
- package/dist/commands/skill/review.js.map +1 -1
- package/dist/commands/skill/test/run.d.ts +1 -1
- package/dist/commands/skill/test/run.d.ts.map +1 -1
- package/dist/commands/skill/test/run.js +8 -6
- package/dist/commands/skill/test/run.js.map +1 -1
- package/dist/commands/skills/build.d.ts +140 -0
- package/dist/commands/skills/build.d.ts.map +1 -1
- package/dist/commands/skills/build.js +348 -94
- package/dist/commands/skills/build.js.map +1 -1
- package/dist/commands/skills/command-helpers.d.ts +6 -10
- package/dist/commands/skills/command-helpers.d.ts.map +1 -1
- package/dist/commands/skills/command-helpers.js +6 -22
- package/dist/commands/skills/command-helpers.js.map +1 -1
- package/dist/commands/skills/list.d.ts +18 -0
- package/dist/commands/skills/list.d.ts.map +1 -1
- package/dist/commands/skills/list.js +41 -15
- package/dist/commands/skills/list.js.map +1 -1
- package/dist/commands/skills/package.d.ts +17 -0
- package/dist/commands/skills/package.d.ts.map +1 -1
- package/dist/commands/skills/package.js +72 -43
- package/dist/commands/skills/package.js.map +1 -1
- package/dist/commands/skills/skill-discovery.d.ts.map +1 -1
- package/dist/commands/skills/skill-discovery.js +9 -1
- package/dist/commands/skills/skill-discovery.js.map +1 -1
- package/dist/commands/skills/validate-command.d.ts.map +1 -1
- package/dist/commands/skills/validate-command.js +22 -6
- package/dist/commands/skills/validate-command.js.map +1 -1
- package/dist/commands/skills/validate.d.ts +79 -0
- package/dist/commands/skills/validate.d.ts.map +1 -1
- package/dist/commands/skills/validate.js +268 -79
- package/dist/commands/skills/validate.js.map +1 -1
- package/dist/commands/validate.d.ts +18 -0
- package/dist/commands/validate.d.ts.map +1 -1
- package/dist/commands/validate.js +79 -66
- package/dist/commands/validate.js.map +1 -1
- package/dist/commands/verify.d.ts +38 -1
- package/dist/commands/verify.d.ts.map +1 -1
- package/dist/commands/verify.js +247 -84
- package/dist/commands/verify.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -1
- package/dist/index.js.map +1 -1
- package/dist/skill-resolution/index.d.ts +1 -1
- package/dist/skill-resolution/index.d.ts.map +1 -1
- package/dist/skill-resolution/index.js +1 -1
- package/dist/skill-resolution/index.js.map +1 -1
- package/dist/skill-resolution/packaging-config.d.ts +15 -2
- package/dist/skill-resolution/packaging-config.d.ts.map +1 -1
- package/dist/skill-resolution/packaging-config.js +39 -2
- package/dist/skill-resolution/packaging-config.js.map +1 -1
- package/dist/utils/command-error.d.ts +34 -1
- package/dist/utils/command-error.d.ts.map +1 -1
- package/dist/utils/command-error.js +32 -1
- package/dist/utils/command-error.js.map +1 -1
- package/dist/utils/config-loader.d.ts.map +1 -1
- package/dist/utils/config-loader.js +0 -2
- package/dist/utils/config-loader.js.map +1 -1
- package/dist/utils/help-loader.d.ts +6 -2
- package/dist/utils/help-loader.d.ts.map +1 -1
- package/dist/utils/help-loader.js +9 -16
- package/dist/utils/help-loader.js.map +1 -1
- package/dist/utils/issue-anchor.d.ts +26 -0
- package/dist/utils/issue-anchor.d.ts.map +1 -0
- package/dist/utils/issue-anchor.js +39 -0
- package/dist/utils/issue-anchor.js.map +1 -0
- package/dist/utils/issue-rendering.d.ts +149 -0
- package/dist/utils/issue-rendering.d.ts.map +1 -0
- package/dist/utils/issue-rendering.js +229 -0
- package/dist/utils/issue-rendering.js.map +1 -0
- package/dist/utils/output.d.ts +80 -9
- package/dist/utils/output.d.ts.map +1 -1
- package/dist/utils/output.js +138 -23
- package/dist/utils/output.js.map +1 -1
- package/dist/utils/project-root-policy.d.ts +2 -2
- package/dist/utils/project-root-policy.js +2 -2
- package/dist/utils/relativize-paths.d.ts +45 -0
- package/dist/utils/relativize-paths.d.ts.map +1 -0
- package/dist/utils/relativize-paths.js +67 -0
- package/dist/utils/relativize-paths.js.map +1 -0
- package/dist/utils/resource-loader.d.ts +21 -2
- package/dist/utils/resource-loader.d.ts.map +1 -1
- package/dist/utils/resource-loader.js +85 -11
- package/dist/utils/resource-loader.js.map +1 -1
- package/dist/utils/skill-packaging-config.d.ts +22 -1
- package/dist/utils/skill-packaging-config.d.ts.map +1 -1
- package/dist/utils/skill-packaging-config.js +26 -0
- package/dist/utils/skill-packaging-config.js.map +1 -1
- package/dist/utils/validate-help-files.d.ts +3 -2
- package/dist/utils/validate-help-files.d.ts.map +1 -1
- package/dist/utils/validate-help-files.js +48 -5
- package/dist/utils/validate-help-files.js.map +1 -1
- package/dist/utils/verdict-helpers.d.ts +14 -5
- package/dist/utils/verdict-helpers.d.ts.map +1 -1
- package/dist/utils/verdict-helpers.js +19 -16
- package/dist/utils/verdict-helpers.js.map +1 -1
- package/docs/audit.md +40 -14
- package/docs/doctor.md +46 -10
- package/docs/index.md +1 -1
- package/docs/rag.md +9 -4
- package/docs/resources.md +54 -22
- package/docs/skills.md +11 -7
- package/package.json +12 -12
package/dist/commands/audit.js
CHANGED
|
@@ -4,17 +4,20 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import * as fs from 'node:fs';
|
|
6
6
|
import { existsSync as fsExistsSync } from 'node:fs';
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
7
|
+
import { calculateValidationStatus, countBySeverity, } from '@vibe-agent-toolkit/agent-schema';
|
|
8
|
+
import { crawlAndResolveRegistry, detectDeclaredButMissing, detectMarketplacePluginSourceMissing, resetPackagingRegistryCache, detectPresentButUndeclared, detectReferenceTargetMissing, detectResourceFormat, enumerateSurfaces, validate, validateMarketplace, validateSkill, validateSkillForPackaging, } from '@vibe-agent-toolkit/agent-skills';
|
|
9
|
+
import { analyzeCompatibility, checkSettingsCompatibility, detectSkillClaudePluginNameMismatch, crawlSkillLinkRegistry, extractClaudeMarketplaceInventory, extractClaudePluginInventory, getClaudeUserPaths, readEffectiveSettings, validatePlugin, } from '@vibe-agent-toolkit/claude-marketplace';
|
|
9
10
|
import { detectFormat } from '@vibe-agent-toolkit/discovery';
|
|
10
|
-
import { findProjectRoot, gitFindRoot, GitTracker, isAbsolutePath, isGitUrl, parseGitUrl, resetProjectRootCaches, safePath, } from '@vibe-agent-toolkit/utils';
|
|
11
|
+
import { findProjectRoot, gitFindRoot, GitTracker, isAbsolutePath, isGitUrl, issueLocation, parseGitUrl, resetProjectRootCaches, safePath, } from '@vibe-agent-toolkit/utils';
|
|
11
12
|
import { Command } from 'commander';
|
|
12
13
|
import picomatch from 'picomatch';
|
|
13
|
-
import { resetSkillDiscoveryCache, resolveSkillPackagingConfig, stripValidationAllowForDisplay, } from '../skill-resolution/packaging-config.js';
|
|
14
|
+
import { resetSkillDiscoveryCache, resolveProjectDeclaredEvalSuites, resolveSkillPackagingConfig, stripValidationAllowForDisplay, } from '../skill-resolution/packaging-config.js';
|
|
14
15
|
import { handleCommandError } from '../utils/command-error.js';
|
|
15
16
|
import { ConfigLoadError, loadConfig, resetLoadedConfigCache, } from '../utils/config-loader.js';
|
|
17
|
+
import { formatIssueLines, formatSeverityBreakdown, issuesToRenderAtVerbosity, } from '../utils/issue-rendering.js';
|
|
16
18
|
import { createLogger } from '../utils/logger.js';
|
|
17
19
|
import { writeYamlOutput } from '../utils/output.js';
|
|
20
|
+
import { relativizePathEntries } from '../utils/relativize-paths.js';
|
|
18
21
|
import { mergeSkillPackagingConfig } from '../utils/skill-packaging-config.js';
|
|
19
22
|
import { renderSkillQualityFooter } from '../utils/skill-quality-footer.js';
|
|
20
23
|
import { computeConfigVerdicts } from '../utils/verdict-helpers.js';
|
|
@@ -87,33 +90,53 @@ async function buildVATProjectContext(scanRoot, logger) {
|
|
|
87
90
|
* Compat verdicts (COMPAT_TARGET_*) are computed from the result's observations
|
|
88
91
|
* and the config-level targets, then merged into the issue list.
|
|
89
92
|
*/
|
|
90
|
-
|
|
93
|
+
/**
|
|
94
|
+
* The one-line human summary of a severity distribution.
|
|
95
|
+
*
|
|
96
|
+
* Every result that carries findings renders its counts the same way, so a reader
|
|
97
|
+
* comparing two entries is comparing the same sentence.
|
|
98
|
+
*/
|
|
99
|
+
function formatCountsSummary(counts) {
|
|
100
|
+
return `${counts.errors} errors, ${counts.warnings} warnings, ${counts.info} info`;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Append findings to a result and re-derive everything that describes them.
|
|
104
|
+
*
|
|
105
|
+
* The audit pipeline appends detector findings AFTER the primary validator has
|
|
106
|
+
* already published a status, a counts block and a summary line. Pushing into
|
|
107
|
+
* `issues` alone left all three describing the pre-append issue list — a plugin
|
|
108
|
+
* with a warning reported `status: success`, `issueCounts: {0,0,0}` and
|
|
109
|
+
* `summary: Valid plugin` in the very document that listed the warning.
|
|
110
|
+
*
|
|
111
|
+
* Mutates in place, because the audit pipeline hands these results around by
|
|
112
|
+
* reference and there is no single place a rebuilt copy could be swapped in.
|
|
113
|
+
*/
|
|
114
|
+
function appendIssues(result, issues) {
|
|
115
|
+
if (issues.length === 0) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
result.issues.push(...issues);
|
|
119
|
+
const counts = countBySeverity(result.issues);
|
|
120
|
+
result.status = calculateValidationStatus(result.issues);
|
|
121
|
+
result.issueCounts = counts;
|
|
122
|
+
result.summary = formatCountsSummary(counts);
|
|
123
|
+
}
|
|
124
|
+
function packagingResultToValidationResult(skillPath, result, configTargets, locationRoot) {
|
|
91
125
|
// allErrors contains all emitted issues (errors + warnings) after severity resolution
|
|
92
126
|
// but without allow suppression — exactly what audit wants. Verdicts are
|
|
93
127
|
// computed from the observations carried on the result.
|
|
94
|
-
const verdictIssues = computeConfigVerdicts(result.observations, configTargets, skillPath);
|
|
128
|
+
const verdictIssues = computeConfigVerdicts(result.observations, configTargets, skillPath, locationRoot);
|
|
95
129
|
const issues = verdictIssues.length > 0
|
|
96
130
|
? [...result.allErrors, ...verdictIssues]
|
|
97
131
|
: result.allErrors;
|
|
98
|
-
const
|
|
99
|
-
const warningCount = issues.filter(i => i.severity === 'warning').length;
|
|
100
|
-
const infoCount = issues.filter(i => i.severity === 'info').length;
|
|
101
|
-
let status;
|
|
102
|
-
if (errorCount > 0) {
|
|
103
|
-
status = 'error';
|
|
104
|
-
}
|
|
105
|
-
else if (warningCount > 0) {
|
|
106
|
-
status = 'warning';
|
|
107
|
-
}
|
|
108
|
-
else {
|
|
109
|
-
status = 'success';
|
|
110
|
-
}
|
|
132
|
+
const issueCounts = countBySeverity(issues);
|
|
111
133
|
const out = {
|
|
112
134
|
path: skillPath,
|
|
113
135
|
type: RESOURCE_TYPE_AGENT_SKILL,
|
|
114
|
-
status,
|
|
115
|
-
summary:
|
|
136
|
+
status: calculateValidationStatus(issues),
|
|
137
|
+
summary: formatCountsSummary(issueCounts),
|
|
116
138
|
issues,
|
|
139
|
+
issueCounts,
|
|
117
140
|
metadata: {
|
|
118
141
|
lineCount: result.metadata.skillLines,
|
|
119
142
|
name: result.skillName,
|
|
@@ -128,7 +151,7 @@ function packagingResultToValidationResult(skillPath, result, configTargets) {
|
|
|
128
151
|
* Validate a single SKILL.md file, using config-aware validation if available.
|
|
129
152
|
* Used for direct SKILL.md and vat-agent paths passed to `vat audit`.
|
|
130
153
|
*/
|
|
131
|
-
async function validateSingleSkill(skillPath, options, logger, isVATGenerated) {
|
|
154
|
+
async function validateSingleSkill(skillPath, options, logger, locationRoot, isVATGenerated) {
|
|
132
155
|
// Try config-aware validation: walk UP to the skill's nearest-ancestor
|
|
133
156
|
// vibe-agent-toolkit.config.yaml and apply the skill's packaging block. A
|
|
134
157
|
// broken governing config is tolerated here (audit is a bulk linter): log it
|
|
@@ -146,13 +169,28 @@ async function validateSingleSkill(skillPath, options, logger, isVATGenerated) {
|
|
|
146
169
|
const skillConfig = fullConfig === null ? null : stripValidationAllowForDisplay(fullConfig);
|
|
147
170
|
if (skillConfig !== null) {
|
|
148
171
|
logger.debug(` Using config-aware validation for: ${skillPath}`);
|
|
149
|
-
const { gitTracker } = await resolveScanContext(safePath.resolve(skillPath));
|
|
150
|
-
|
|
172
|
+
const { gitTracker } = await resolveScanContext(safePath.resolve(skillPath), locationRoot, logger);
|
|
173
|
+
// Key the shared registry on the same project root the validator derives,
|
|
174
|
+
// so every skill under one root parses the corpus once for the whole run
|
|
175
|
+
// instead of once each.
|
|
176
|
+
const skillDir = safePath.resolve(skillPath, '..');
|
|
177
|
+
const projectRoot = findProjectRoot(skillDir) ?? skillDir;
|
|
178
|
+
// The project's declared eval suites, memoized per config root, so this lane
|
|
179
|
+
// predicts the same bundle the packager produces: no skill's report counts a
|
|
180
|
+
// SIBLING skill's eval suite as an ordinary bundled file.
|
|
181
|
+
const sharedCtx = {
|
|
182
|
+
registry: await crawlAndResolveRegistry(projectRoot),
|
|
183
|
+
locationRoot,
|
|
184
|
+
projectSkills: await resolveProjectDeclaredEvalSuites(skillPath),
|
|
185
|
+
};
|
|
186
|
+
if (gitTracker !== null) {
|
|
187
|
+
sharedCtx.gitTracker = gitTracker;
|
|
188
|
+
}
|
|
151
189
|
const packagingResult = await validateSkillForPackaging(skillPath, skillConfig, 'source', sharedCtx);
|
|
152
|
-
return packagingResultToValidationResult(skillPath, packagingResult, skillConfig.targets);
|
|
190
|
+
return packagingResultToValidationResult(skillPath, packagingResult, skillConfig.targets, locationRoot);
|
|
153
191
|
}
|
|
154
192
|
// Fallback: basic validation
|
|
155
|
-
const validateOptions = { skillPath };
|
|
193
|
+
const validateOptions = { skillPath, locationRoot };
|
|
156
194
|
if (options.warnUnreferencedFiles) {
|
|
157
195
|
validateOptions.checkUnreferencedFiles = true;
|
|
158
196
|
}
|
|
@@ -175,7 +213,7 @@ export function createAuditCommand() {
|
|
|
175
213
|
const audit = new Command('audit');
|
|
176
214
|
audit
|
|
177
215
|
.description('Audit Claude plugins, marketplaces, registries, and skills')
|
|
178
|
-
.argument('[git-url-or-path]', 'Path or git URL to audit. URL forms: https://host/owner/repo.git[#ref[:subpath]], git@host:owner/repo.git, ssh://..., owner/repo (GitHub shorthand), or a GitHub web URL like https://github.com/owner/repo/tree/<ref>/<subpath>. Default: current directory.')
|
|
216
|
+
.argument('[git-url-or-path]', 'Path or git URL to audit. An argument that names an existing file or directory is always treated as a path. Otherwise, URL forms: https://host/owner/repo.git[#ref[:subpath]], git@host:owner/repo.git, ssh://..., owner/repo (GitHub shorthand), or a GitHub web URL like https://github.com/owner/repo/tree/<ref>/<subpath>. Default: current directory.')
|
|
179
217
|
.option('--no-recursive', 'Disable recursive directory scanning (scans top level only)')
|
|
180
218
|
.option('--exclude <glob>', 'Exclude paths matching glob pattern (repeatable)', collect, [])
|
|
181
219
|
.option('--include-artifacts', 'Include gitignored paths (build artifacts, dependencies) that are excluded by default')
|
|
@@ -208,8 +246,12 @@ Description:
|
|
|
208
246
|
|
|
209
247
|
Path or URL to audit: resource directory, registry file, SKILL.md file,
|
|
210
248
|
scan directory, or a git URL (HTTPS, SSH, GitHub shorthand, GitHub web URL).
|
|
211
|
-
|
|
212
|
-
|
|
249
|
+
An argument naming an existing file or directory is always a path — bare
|
|
250
|
+
owner/repo shorthand only applies when nothing of that name exists
|
|
251
|
+
locally, so "vat audit plugins/arc" audits your directory and never
|
|
252
|
+
contacts the network. When given a URL, VAT shallow-clones to a temp
|
|
253
|
+
directory, audits, and cleans up on exit. See packages/cli/docs/audit.md
|
|
254
|
+
for URL forms.
|
|
213
255
|
Default: current directory
|
|
214
256
|
Use --user to audit user-level installation automatically
|
|
215
257
|
|
|
@@ -298,7 +340,10 @@ Examples:
|
|
|
298
340
|
* and outputs hierarchical YAML. Calls process.exit() when done.
|
|
299
341
|
*/
|
|
300
342
|
async function auditUserDirectories(recursive, options, startTime, logger) {
|
|
301
|
-
const { pluginsDir, skillsDir, marketplacesDir } = getClaudeUserPaths();
|
|
343
|
+
const { claudeDir, pluginsDir, skillsDir, marketplacesDir } = getClaudeUserPaths();
|
|
344
|
+
// --user scans three sibling directories; they share ONE enclosing Claude
|
|
345
|
+
// config dir, so that is the run's single stated root.
|
|
346
|
+
const scanRoot = safePath.resolve(claudeDir);
|
|
302
347
|
// eslint-disable-next-line security/detect-non-literal-fs-filename -- safe: path constructed from os.homedir()
|
|
303
348
|
const pluginsDirExists = fs.existsSync(pluginsDir);
|
|
304
349
|
// eslint-disable-next-line security/detect-non-literal-fs-filename -- safe: path constructed from os.homedir()
|
|
@@ -316,29 +361,29 @@ async function auditUserDirectories(recursive, options, startTime, logger) {
|
|
|
316
361
|
const results = [];
|
|
317
362
|
if (pluginsDirExists) {
|
|
318
363
|
logger.debug(`Auditing user-level plugins at: ${pluginsDir}`);
|
|
319
|
-
results.push(...await getValidationResults(pluginsDir, recursive, options, logger));
|
|
364
|
+
results.push(...await getValidationResults(pluginsDir, recursive, options, logger, scanRoot));
|
|
320
365
|
}
|
|
321
366
|
if (skillsDirExists) {
|
|
322
367
|
logger.debug(`Auditing user-level skills at: ${skillsDir}`);
|
|
323
|
-
results.push(...await getValidationResults(skillsDir, recursive, options, logger));
|
|
368
|
+
results.push(...await getValidationResults(skillsDir, recursive, options, logger, scanRoot));
|
|
324
369
|
}
|
|
325
370
|
if (marketplacesDirExists) {
|
|
326
371
|
logger.debug(`Auditing user-level marketplaces at: ${marketplacesDir}`);
|
|
327
|
-
results.push(...await getValidationResults(marketplacesDir, recursive, options, logger));
|
|
372
|
+
results.push(...await getValidationResults(marketplacesDir, recursive, options, logger, scanRoot));
|
|
328
373
|
}
|
|
329
374
|
// Run compatibility analysis if --compat flag is set
|
|
330
375
|
const compatMap = options.compat
|
|
331
|
-
? await runCompatAnalysis(results, logger) // --settings not supported in --user mode
|
|
376
|
+
? await runCompatAnalysis(results, logger, scanRoot) // --settings not supported in --user mode
|
|
332
377
|
: undefined;
|
|
333
378
|
const verbose = options.verbose ?? false;
|
|
334
379
|
const skillResults = results.filter((r) => SKILL_RESULT_TYPES.has(r.type));
|
|
335
|
-
const hierarchical = buildHierarchicalOutput(skillResults, verbose);
|
|
336
|
-
const summary = calculateHierarchicalSummary(results, hierarchical, startTime, compatMap, verbose);
|
|
380
|
+
const hierarchical = buildHierarchicalOutput(skillResults, verbose, scanRoot);
|
|
381
|
+
const summary = calculateHierarchicalSummary(results, hierarchical, startTime, compatMap, verbose, scanRoot);
|
|
337
382
|
writeYamlOutput(summary);
|
|
338
383
|
if (verbose) {
|
|
339
|
-
renderVerboseEvidence(results, logger);
|
|
384
|
+
renderVerboseEvidence(results, scanRoot, logger);
|
|
340
385
|
}
|
|
341
|
-
logHierarchicalSummary(results,
|
|
386
|
+
logHierarchicalSummary(results, logger);
|
|
342
387
|
}
|
|
343
388
|
/**
|
|
344
389
|
* Apply severity filtering to validation results.
|
|
@@ -358,6 +403,30 @@ async function auditUserDirectories(recursive, options, startTime, logger) {
|
|
|
358
403
|
*/
|
|
359
404
|
/** Skill resource types that can have per-skill validation config. */
|
|
360
405
|
const SKILL_RESULT_TYPES = new Set([RESOURCE_TYPE_AGENT_SKILL, 'vat-agent']);
|
|
406
|
+
/**
|
|
407
|
+
* The invocation scan root: the ONE base every `path` and `location` in a
|
|
408
|
+
* report is expressed relative to.
|
|
409
|
+
*
|
|
410
|
+
* This is an ANCHOR, not a validation-policy boundary. Deliberately NOT
|
|
411
|
+
* {@link deriveConfigRoot} — a config root is discovered per resource by
|
|
412
|
+
* walking up, and a single `vat audit` spans many of them, so using it as the
|
|
413
|
+
* anchor emits one document in many coordinate systems. The scan root is
|
|
414
|
+
* whatever the operator pointed the command at, so it is well defined even
|
|
415
|
+
* where no config or git root exists at all (auditing `~/.claude`, a plugin
|
|
416
|
+
* cache, a bare directory of skills).
|
|
417
|
+
*
|
|
418
|
+
* A file target anchors at its containing directory, so its own `path` reads as
|
|
419
|
+
* the bare filename rather than as an empty string.
|
|
420
|
+
*
|
|
421
|
+
* @internal Exported so tests and the corpus runner derive the run root the same
|
|
422
|
+
* way the command does, instead of hand-rolling a second answer.
|
|
423
|
+
*/
|
|
424
|
+
export function deriveScanRoot(targetPath) {
|
|
425
|
+
const resolved = safePath.resolve(targetPath);
|
|
426
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename -- resolved is the operator's own audit target
|
|
427
|
+
const isDirectory = fs.existsSync(resolved) && fs.statSync(resolved).isDirectory();
|
|
428
|
+
return isDirectory ? resolved : safePath.resolve(resolved, '..');
|
|
429
|
+
}
|
|
361
430
|
/**
|
|
362
431
|
* Derive the project root directory for config loading.
|
|
363
432
|
*
|
|
@@ -377,23 +446,15 @@ function deriveConfigRoot(targetPath) {
|
|
|
377
446
|
* Build the recalculated status and summary after issues have been filtered.
|
|
378
447
|
*/
|
|
379
448
|
function buildFilteredResult(result, filteredIssues) {
|
|
380
|
-
const
|
|
381
|
-
const warningCount = filteredIssues.filter(i => i.severity === 'warning').length;
|
|
382
|
-
const infoCount = filteredIssues.filter(i => i.severity === 'info').length;
|
|
383
|
-
let status;
|
|
384
|
-
if (errorCount > 0) {
|
|
385
|
-
status = 'error';
|
|
386
|
-
}
|
|
387
|
-
else if (warningCount > 0) {
|
|
388
|
-
status = 'warning';
|
|
389
|
-
}
|
|
390
|
-
else {
|
|
391
|
-
status = 'success';
|
|
392
|
-
}
|
|
449
|
+
const counts = countBySeverity(filteredIssues);
|
|
393
450
|
return {
|
|
394
451
|
...result,
|
|
395
|
-
status,
|
|
396
|
-
|
|
452
|
+
status: calculateValidationStatus(filteredIssues),
|
|
453
|
+
// Spreading `result` carried the PRE-filter `issueCounts` forward, so a
|
|
454
|
+
// `severity: ignore` config left the counts describing findings the report
|
|
455
|
+
// no longer contained. The counts are re-derived, never inherited.
|
|
456
|
+
issueCounts: counts,
|
|
457
|
+
summary: formatCountsSummary(counts),
|
|
397
458
|
issues: filteredIssues,
|
|
398
459
|
};
|
|
399
460
|
}
|
|
@@ -459,12 +520,18 @@ async function resolveEffectiveSettings(options, scanPath, logger) {
|
|
|
459
520
|
return undefined;
|
|
460
521
|
}
|
|
461
522
|
}
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
523
|
+
/**
|
|
524
|
+
* Run the audit pipeline at `scanPath` and assemble the report document,
|
|
525
|
+
* without writing anything. The document states its coordinate system once
|
|
526
|
+
* (`root`) and every `path`/`location` beneath it is relative to that root.
|
|
527
|
+
*
|
|
528
|
+
* @internal Exported for integration testing only — not part of the public CLI API.
|
|
529
|
+
*/
|
|
530
|
+
export async function buildAuditReport(scanPath, options, startTime, logger) {
|
|
465
531
|
const recursive = options.recursive !== false;
|
|
466
532
|
logger.debug(`Auditing resources at: ${scanPath}`);
|
|
467
|
-
const
|
|
533
|
+
const scanRoot = deriveScanRoot(scanPath);
|
|
534
|
+
const rawResults = await getValidationResults(scanPath, recursive, options, logger, scanRoot);
|
|
468
535
|
// Load config for severity filtering (audit ignores allow; only severity matters).
|
|
469
536
|
const config = loadConfig(deriveConfigRoot(scanPath));
|
|
470
537
|
// Apply severity filtering: hide codes whose effective severity is 'ignore'.
|
|
@@ -477,19 +544,38 @@ async function runAuditAtPath(scanPath, options, overrides = {}) {
|
|
|
477
544
|
const vatContextForCompat = await buildVATProjectContext(deriveConfigRoot(scanPath), logger);
|
|
478
545
|
// Run compatibility analysis if --compat flag is set
|
|
479
546
|
const compatMap = options.compat
|
|
480
|
-
? await runCompatAnalysis(results, logger, effectiveSettings, vatContextForCompat)
|
|
547
|
+
? await runCompatAnalysis(results, logger, scanRoot, effectiveSettings, vatContextForCompat)
|
|
481
548
|
: undefined;
|
|
482
549
|
const verbose = options.verbose ?? false;
|
|
483
|
-
|
|
484
|
-
|
|
550
|
+
return {
|
|
551
|
+
results,
|
|
552
|
+
document: calculateSummary(results, startTime, compatMap, verbose, scanRoot),
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
/** Strip the run root from a report whose base cannot be named as a path. */
|
|
556
|
+
function withoutRoot(document) {
|
|
557
|
+
const copy = { ...document };
|
|
558
|
+
delete copy['root'];
|
|
559
|
+
return copy;
|
|
560
|
+
}
|
|
561
|
+
async function runAuditAtPath(scanPath, options, overrides = {}) {
|
|
562
|
+
const logger = createLogger(options.debug ? { debug: true } : {});
|
|
563
|
+
const startTime = Date.now();
|
|
564
|
+
const verbose = options.verbose ?? false;
|
|
565
|
+
const { results, document } = await buildAuditReport(scanPath, options, startTime, logger);
|
|
566
|
+
// A URL audit's run root is a random tempdir: not reproducible and useless to
|
|
567
|
+
// a reader. The provenance header states the base instead (url @ ref @ commit)
|
|
568
|
+
// and every path below is already relative to the clone, so drop `root`
|
|
569
|
+
// rather than print a path nobody can resolve.
|
|
570
|
+
const finalDocument = overrides.tempRoot === undefined ? document : rewritePathsInResults(withoutRoot(document), overrides.tempRoot);
|
|
485
571
|
if (overrides.provenance) {
|
|
486
572
|
process.stdout.write(renderProvenanceHeader(overrides.provenance));
|
|
487
573
|
}
|
|
488
|
-
writeYamlOutput(
|
|
574
|
+
writeYamlOutput(finalDocument);
|
|
489
575
|
if (verbose) {
|
|
490
|
-
renderVerboseEvidence(results, logger);
|
|
576
|
+
renderVerboseEvidence(results, document.root, logger);
|
|
491
577
|
}
|
|
492
|
-
handleAuditResults(results,
|
|
578
|
+
handleAuditResults(results, document, logger, verbose);
|
|
493
579
|
logger.debug(`Audit complete in ${Date.now() - startTime}ms`);
|
|
494
580
|
}
|
|
495
581
|
async function runUrlAudit(rawInput, options) {
|
|
@@ -509,7 +595,15 @@ export async function auditCommand(targetPath, options) {
|
|
|
509
595
|
const startTime = Date.now();
|
|
510
596
|
resetAuditCaches();
|
|
511
597
|
try {
|
|
512
|
-
|
|
598
|
+
// A local path always wins. `isGitUrl` accepts bare `owner/repo` GitHub
|
|
599
|
+
// shorthand, which is indistinguishable from an ordinary two-segment
|
|
600
|
+
// relative path (`plugins/arc`, `docs/guides`, `packages/cli`). Asking
|
|
601
|
+
// the URL question first meant those never got audited — the command
|
|
602
|
+
// cloned https://github.com/<that>.git instead, reaching out to the
|
|
603
|
+
// network with a name derived from the caller's own directory layout.
|
|
604
|
+
// Shorthand is a fallback for arguments that name nothing on disk.
|
|
605
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename -- targetPath is the user's own audit target; existence is the question being asked
|
|
606
|
+
if (targetPath !== undefined && !fsExistsSync(targetPath) && isGitUrl(targetPath)) {
|
|
513
607
|
await runUrlAudit(targetPath, options);
|
|
514
608
|
return;
|
|
515
609
|
}
|
|
@@ -531,24 +625,24 @@ export async function auditCommand(targetPath, options) {
|
|
|
531
625
|
* branch of {@link getValidationResults} focused so cognitive complexity stays
|
|
532
626
|
* within the ESLint budget.
|
|
533
627
|
*/
|
|
534
|
-
async function validateSurface(surface, options, logger) {
|
|
628
|
+
async function validateSurface(surface, options, logger, locationRoot) {
|
|
535
629
|
if (surface.type === RESOURCE_TYPE_AGENT_SKILL) {
|
|
536
|
-
return validateSingleSkill(surface.path, options, logger);
|
|
630
|
+
return validateSingleSkill(surface.path, options, logger, locationRoot);
|
|
537
631
|
}
|
|
538
632
|
if (surface.type === RESOURCE_TYPE_CLAUDE_PLUGIN) {
|
|
539
|
-
return validatePlugin(surface.path);
|
|
633
|
+
return validatePlugin(surface.path, { locationRoot });
|
|
540
634
|
}
|
|
541
|
-
return validateMarketplace(surface.path);
|
|
635
|
+
return validateMarketplace(surface.path, { locationRoot });
|
|
542
636
|
}
|
|
543
637
|
/**
|
|
544
638
|
* Validate every surface enumerated at a directory root (used when
|
|
545
639
|
* `enumerateSurfaces` returns more than one — e.g., skill-claude-plugin).
|
|
546
640
|
*/
|
|
547
|
-
async function validateMultipleSurfaces(surfaces, scanPath, options, logger) {
|
|
641
|
+
async function validateMultipleSurfaces(surfaces, scanPath, options, logger, locationRoot) {
|
|
548
642
|
logger.debug(`Detected ${surfaces.length.toString()} surfaces at ${scanPath}: ${surfaces.map((s) => s.type).join(', ')}`);
|
|
549
643
|
const results = [];
|
|
550
644
|
for (const surface of surfaces) {
|
|
551
|
-
results.push(await validateSurface(surface, options, logger));
|
|
645
|
+
results.push(await validateSurface(surface, options, logger, locationRoot));
|
|
552
646
|
}
|
|
553
647
|
return results;
|
|
554
648
|
}
|
|
@@ -563,19 +657,19 @@ async function validateMultipleSurfaces(surfaces, scanPath, options, logger) {
|
|
|
563
657
|
* already extracted the inventory (e.g., to surface parse errors) and the
|
|
564
658
|
* extractor is not called again.
|
|
565
659
|
*/
|
|
566
|
-
async function runInventoryDetectors(scanPath, type, prebuiltInv) {
|
|
660
|
+
async function runInventoryDetectors(scanPath, type, locationRoot, prebuiltInv) {
|
|
567
661
|
if (type === RESOURCE_TYPE_CLAUDE_PLUGIN) {
|
|
568
|
-
const inv = prebuiltInv?.kind === 'plugin' ? prebuiltInv : await
|
|
662
|
+
const inv = prebuiltInv?.kind === 'plugin' ? prebuiltInv : await pluginInventoryAt(scanPath);
|
|
569
663
|
return [
|
|
570
|
-
...detectDeclaredButMissing(inv),
|
|
571
|
-
...detectPresentButUndeclared(inv),
|
|
572
|
-
...detectReferenceTargetMissing(inv),
|
|
573
|
-
...(inv.vendor === 'claude-code' ? detectSkillClaudePluginNameMismatch(inv) : []),
|
|
664
|
+
...detectDeclaredButMissing(inv, locationRoot),
|
|
665
|
+
...detectPresentButUndeclared(inv, locationRoot),
|
|
666
|
+
...detectReferenceTargetMissing(inv, locationRoot),
|
|
667
|
+
...(inv.vendor === 'claude-code' ? detectSkillClaudePluginNameMismatch(inv, locationRoot) : []),
|
|
574
668
|
];
|
|
575
669
|
}
|
|
576
670
|
if (type === 'marketplace') {
|
|
577
671
|
const inv = prebuiltInv?.kind === 'marketplace' ? prebuiltInv : await extractClaudeMarketplaceInventory(scanPath);
|
|
578
|
-
return detectMarketplacePluginSourceMissing(inv);
|
|
672
|
+
return detectMarketplacePluginSourceMissing(inv, locationRoot);
|
|
579
673
|
}
|
|
580
674
|
return [];
|
|
581
675
|
}
|
|
@@ -590,7 +684,7 @@ async function runInventoryDetectors(scanPath, type, prebuiltInv) {
|
|
|
590
684
|
* compared against the marketplace path itself to avoid re-auditing a
|
|
591
685
|
* co-located self-source (e.g., `source: "./"`).
|
|
592
686
|
*/
|
|
593
|
-
async function recurseIntoMarketplacePlugins(marketplaceInv, recursive, options, logger) {
|
|
687
|
+
async function recurseIntoMarketplacePlugins(marketplaceInv, recursive, options, logger, locationRoot) {
|
|
594
688
|
const marketplaceRoot = safePath.resolve(marketplaceInv.path);
|
|
595
689
|
const seen = new Set([marketplaceRoot]);
|
|
596
690
|
const results = [];
|
|
@@ -602,7 +696,8 @@ async function recurseIntoMarketplacePlugins(marketplaceInv, recursive, options,
|
|
|
602
696
|
}
|
|
603
697
|
seen.add(pluginPath);
|
|
604
698
|
logger.debug(` Recursing into marketplace path-source plugin: ${pluginPath}`);
|
|
605
|
-
|
|
699
|
+
// Same run root — recursing into a co-located plugin must NOT re-anchor.
|
|
700
|
+
const pluginResults = await getValidationResults(pluginPath, recursive, options, logger, locationRoot);
|
|
606
701
|
results.push(...pluginResults);
|
|
607
702
|
}
|
|
608
703
|
return results;
|
|
@@ -611,12 +706,12 @@ async function recurseIntoMarketplacePlugins(marketplaceInv, recursive, options,
|
|
|
611
706
|
* Append plugin inventory findings to the matching plugin result in a
|
|
612
707
|
* multi-surface result list. Mutates in place.
|
|
613
708
|
*/
|
|
614
|
-
async function appendPluginInventoryToSurfaceResults(surfaceResults, scanPath, logger) {
|
|
615
|
-
const inventoryIssues = await runInventoryDetectors(scanPath, RESOURCE_TYPE_CLAUDE_PLUGIN);
|
|
709
|
+
async function appendPluginInventoryToSurfaceResults(surfaceResults, scanPath, logger, locationRoot) {
|
|
710
|
+
const inventoryIssues = await runInventoryDetectors(scanPath, RESOURCE_TYPE_CLAUDE_PLUGIN, locationRoot);
|
|
616
711
|
logger.debug(`Inventory detectors emitted ${inventoryIssues.length.toString()} issues for plugin (multi-surface) at ${scanPath}`);
|
|
617
712
|
for (const r of surfaceResults) {
|
|
618
713
|
if (r.type === RESOURCE_TYPE_CLAUDE_PLUGIN) {
|
|
619
|
-
r
|
|
714
|
+
appendIssues(r, inventoryIssues);
|
|
620
715
|
break;
|
|
621
716
|
}
|
|
622
717
|
}
|
|
@@ -627,35 +722,45 @@ async function appendPluginInventoryToSurfaceResults(surfaceResults, scanPath, l
|
|
|
627
722
|
* by `validatePlugin`). Only hooks/hooks.json and .mcp.json parse errors reach
|
|
628
723
|
* this helper; they are appended with error severity.
|
|
629
724
|
*/
|
|
630
|
-
function appendInventoryParseErrors(result, inv) {
|
|
725
|
+
function appendInventoryParseErrors(result, inv, locationRoot) {
|
|
631
726
|
const pluginJsonSuffix = safePath.join('.claude-plugin', 'plugin.json');
|
|
727
|
+
const parseIssues = [];
|
|
632
728
|
for (const err of inv.parseErrors) {
|
|
633
729
|
if (err.path.endsWith(pluginJsonSuffix))
|
|
634
730
|
continue;
|
|
635
|
-
|
|
731
|
+
parseIssues.push({
|
|
636
732
|
severity: 'error',
|
|
637
733
|
code: 'PLUGIN_INVALID_JSON',
|
|
638
734
|
message: err.message,
|
|
639
|
-
location: err.path,
|
|
735
|
+
location: issueLocation(err.path, locationRoot),
|
|
640
736
|
});
|
|
641
|
-
result.status = 'error';
|
|
642
737
|
}
|
|
738
|
+
// Status was hand-set to 'error' here while `issueCounts` and `summary` kept
|
|
739
|
+
// describing the pre-append list. One appender derives all three.
|
|
740
|
+
appendIssues(result, parseIssues);
|
|
643
741
|
}
|
|
644
742
|
/**
|
|
645
743
|
* @internal Exported for integration testing only — not part of the public CLI API.
|
|
744
|
+
*
|
|
745
|
+
* @param locationRoot - The invocation scan root: the ONE base every emitted
|
|
746
|
+
* `ValidationIssue.location` in this run is expressed relative to. It is an
|
|
747
|
+
* ANCHOR, not a validation-policy boundary — per-skill governing-config
|
|
748
|
+
* discovery still decides packaging rules, but it must never decide how a
|
|
749
|
+
* location reads, or a run spanning several configs emits one document in
|
|
750
|
+
* several coordinate systems. Recursion passes it through unchanged.
|
|
646
751
|
*/
|
|
647
|
-
export async function getValidationResults(scanPath, recursive, options, logger) {
|
|
752
|
+
export async function getValidationResults(scanPath, recursive, options, logger, locationRoot) {
|
|
648
753
|
const format = detectFormat(scanPath);
|
|
649
754
|
// Special handling for direct SKILL.md file
|
|
650
755
|
if (format === RESOURCE_TYPE_AGENT_SKILL) {
|
|
651
756
|
logger.debug('Detected single Agent Skill');
|
|
652
|
-
return [await validateSingleSkill(scanPath, options, logger)];
|
|
757
|
+
return [await validateSingleSkill(scanPath, options, logger, locationRoot)];
|
|
653
758
|
}
|
|
654
759
|
// Special handling for VAT agent: validate its SKILL.md
|
|
655
760
|
if (format === 'vat-agent') {
|
|
656
761
|
const skillPath = safePath.join(scanPath, 'SKILL.md');
|
|
657
762
|
logger.debug('Detected VAT agent, validating SKILL.md');
|
|
658
|
-
return [await validateSingleSkill(skillPath, options, logger, true)];
|
|
763
|
+
return [await validateSingleSkill(skillPath, options, logger, locationRoot, true)];
|
|
659
764
|
}
|
|
660
765
|
// Enumerate all manifest surfaces at the directory root. If multiple are
|
|
661
766
|
// present (e.g., skill-claude-plugin: SKILL.md + .claude-plugin/plugin.json),
|
|
@@ -664,7 +769,7 @@ export async function getValidationResults(scanPath, recursive, options, logger)
|
|
|
664
769
|
// swallowed by the plugin surface.
|
|
665
770
|
const surfaces = await enumerateSurfaces(scanPath);
|
|
666
771
|
if (surfaces.length > 1) {
|
|
667
|
-
const surfaceResults = await validateMultipleSurfaces(surfaces, scanPath, options, logger);
|
|
772
|
+
const surfaceResults = await validateMultipleSurfaces(surfaces, scanPath, options, logger, locationRoot);
|
|
668
773
|
if (surfaces.some((s) => s.type === RESOURCE_TYPE_CLAUDE_PLUGIN)) {
|
|
669
774
|
// Build exclusion set from skill paths already validated as surfaces, so that
|
|
670
775
|
// validatePluginSkillsViaInventory does not double-count a root SKILL.md that
|
|
@@ -672,8 +777,8 @@ export async function getValidationResults(scanPath, recursive, options, logger)
|
|
|
672
777
|
const alreadyValidated = new Set(surfaces
|
|
673
778
|
.filter((s) => s.type === RESOURCE_TYPE_AGENT_SKILL)
|
|
674
779
|
.map((s) => safePath.resolve(s.path)));
|
|
675
|
-
surfaceResults.push(...(await validatePluginSkillsViaInventory(scanPath, options, logger, alreadyValidated)));
|
|
676
|
-
await appendPluginInventoryToSurfaceResults(surfaceResults, scanPath, logger);
|
|
780
|
+
surfaceResults.push(...(await validatePluginSkillsViaInventory(scanPath, options, logger, locationRoot, alreadyValidated)));
|
|
781
|
+
await appendPluginInventoryToSurfaceResults(surfaceResults, scanPath, logger, locationRoot);
|
|
677
782
|
}
|
|
678
783
|
return surfaceResults;
|
|
679
784
|
}
|
|
@@ -681,16 +786,16 @@ export async function getValidationResults(scanPath, recursive, options, logger)
|
|
|
681
786
|
const resourceFormat = await detectResourceFormat(scanPath);
|
|
682
787
|
if (resourceFormat.type !== 'unknown') {
|
|
683
788
|
logger.debug(`Detected ${resourceFormat.type} at: ${scanPath}`);
|
|
684
|
-
const result = await validate(scanPath, { validatePlugin });
|
|
789
|
+
const result = await validate(scanPath, { validatePlugin, locationRoot });
|
|
685
790
|
if (resourceFormat.type === RESOURCE_TYPE_CLAUDE_PLUGIN) {
|
|
686
791
|
// Build the inventory once; reuse it for both parse-error surfacing and detectors.
|
|
687
|
-
const inv = await
|
|
688
|
-
appendInventoryParseErrors(result, inv);
|
|
689
|
-
const pluginInventoryIssues = await runInventoryDetectors(scanPath, RESOURCE_TYPE_CLAUDE_PLUGIN, inv);
|
|
690
|
-
result
|
|
792
|
+
const inv = await pluginInventoryAt(scanPath);
|
|
793
|
+
appendInventoryParseErrors(result, inv, locationRoot);
|
|
794
|
+
const pluginInventoryIssues = await runInventoryDetectors(scanPath, RESOURCE_TYPE_CLAUDE_PLUGIN, locationRoot, inv);
|
|
795
|
+
appendIssues(result, pluginInventoryIssues);
|
|
691
796
|
logger.debug(`Inventory detectors emitted ${pluginInventoryIssues.length.toString()} issues for plugin at ${scanPath}`);
|
|
692
797
|
// Also validate every skill the plugin ships via the inventory walker.
|
|
693
|
-
const skillResults = await validatePluginSkillsViaInventory(scanPath, options, logger);
|
|
798
|
+
const skillResults = await validatePluginSkillsViaInventory(scanPath, options, logger, locationRoot);
|
|
694
799
|
return [result, ...skillResults];
|
|
695
800
|
}
|
|
696
801
|
if (resourceFormat.type === 'marketplace') {
|
|
@@ -699,10 +804,10 @@ export async function getValidationResults(scanPath, recursive, options, logger)
|
|
|
699
804
|
// is populated only for path sources that exist on disk (git/npm/unknown
|
|
700
805
|
// sources are declarations only and stay out of scope).
|
|
701
806
|
const marketplaceInv = await extractClaudeMarketplaceInventory(scanPath);
|
|
702
|
-
const marketplaceInventoryIssues = await runInventoryDetectors(scanPath, 'marketplace', marketplaceInv);
|
|
703
|
-
result
|
|
807
|
+
const marketplaceInventoryIssues = await runInventoryDetectors(scanPath, 'marketplace', locationRoot, marketplaceInv);
|
|
808
|
+
appendIssues(result, marketplaceInventoryIssues);
|
|
704
809
|
logger.debug(`Inventory detectors emitted ${marketplaceInventoryIssues.length.toString()} issues for marketplace at ${scanPath}`);
|
|
705
|
-
const pluginResults = await recurseIntoMarketplacePlugins(marketplaceInv, recursive, options, logger);
|
|
810
|
+
const pluginResults = await recurseIntoMarketplacePlugins(marketplaceInv, recursive, options, logger, locationRoot);
|
|
706
811
|
return [result, ...pluginResults];
|
|
707
812
|
}
|
|
708
813
|
return [result];
|
|
@@ -713,14 +818,11 @@ export async function getValidationResults(scanPath, recursive, options, logger)
|
|
|
713
818
|
const stat = await fsp.stat(scanPath);
|
|
714
819
|
if (stat.isDirectory()) {
|
|
715
820
|
logger.debug('Scanning directory for resources');
|
|
716
|
-
//
|
|
717
|
-
//
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
? { ...options, exclude: [...(options.exclude ?? []), ...configExcludes] }
|
|
722
|
-
: options;
|
|
723
|
-
return scanDirectory(scanPath, recursive, mergedOptions, logger);
|
|
821
|
+
// The project's `resources.exclude` is applied by the scan context,
|
|
822
|
+
// on the project-root basis its globs were written against — NOT
|
|
823
|
+
// merged into `options.exclude`, whose patterns are relative to the
|
|
824
|
+
// directory the operator named. See resolveProjectExcludes.
|
|
825
|
+
return scanDirectory(scanPath, recursive, options, logger, await resolveScanContext(scanPath, locationRoot, logger));
|
|
724
826
|
}
|
|
725
827
|
}
|
|
726
828
|
catch {
|
|
@@ -728,7 +830,7 @@ export async function getValidationResults(scanPath, recursive, options, logger)
|
|
|
728
830
|
}
|
|
729
831
|
// Unknown resource type - use unified validator which will return appropriate error
|
|
730
832
|
logger.debug(`Unknown resource type at: ${scanPath}`);
|
|
731
|
-
const result = await validate(scanPath);
|
|
833
|
+
const result = await validate(scanPath, { locationRoot });
|
|
732
834
|
return [result];
|
|
733
835
|
}
|
|
734
836
|
/**
|
|
@@ -740,8 +842,8 @@ export async function getValidationResults(scanPath, recursive, options, logger)
|
|
|
740
842
|
* multi-surface branch to avoid double-counting a root SKILL.md that
|
|
741
843
|
* `validateMultipleSurfaces` already validated as an `agent-skill` surface.
|
|
742
844
|
*/
|
|
743
|
-
async function validatePluginSkillsViaInventory(scanPath, options, logger, excludeSkillPaths = new Set()) {
|
|
744
|
-
const inv = await
|
|
845
|
+
async function validatePluginSkillsViaInventory(scanPath, options, logger, locationRoot, excludeSkillPaths = new Set()) {
|
|
846
|
+
const inv = await pluginInventoryAt(scanPath);
|
|
745
847
|
const results = [];
|
|
746
848
|
for (const skill of inv.discovered.skills) {
|
|
747
849
|
const resolvedPath = safePath.resolve(skill.files.skillMd);
|
|
@@ -750,7 +852,7 @@ async function validatePluginSkillsViaInventory(scanPath, options, logger, exclu
|
|
|
750
852
|
continue;
|
|
751
853
|
}
|
|
752
854
|
logger.debug(` Validating plugin-bundled skill (inventory): ${skill.files.skillMd}`);
|
|
753
|
-
results.push(await validateSingleSkill(skill.files.skillMd, options, logger));
|
|
855
|
+
results.push(await validateSingleSkill(skill.files.skillMd, options, logger, locationRoot));
|
|
754
856
|
}
|
|
755
857
|
return results;
|
|
756
858
|
}
|
|
@@ -795,6 +897,11 @@ function resolveConfigTargetsForPlugin(pluginDir, vatContext) {
|
|
|
795
897
|
* Non-plugin results are skipped silently.
|
|
796
898
|
* When effectiveSettings is provided, also runs settings conflict detection.
|
|
797
899
|
*
|
|
900
|
+
* `locationRoot` is the invocation scan root: the ONE base every emitted
|
|
901
|
+
* evidence `location.file` is relative to. One audit run spans many plugin
|
|
902
|
+
* directories, so anchoring evidence at each plugin would mix coordinate
|
|
903
|
+
* systems in a single document.
|
|
904
|
+
*
|
|
798
905
|
* When vatContext is provided, config-layer `targets` declarations are
|
|
799
906
|
* threaded through to the analyzer so `vat audit .` inside a VAT project
|
|
800
907
|
* matches `vat skills validate` verdicts. See
|
|
@@ -802,7 +909,7 @@ function resolveConfigTargetsForPlugin(pluginDir, vatContext) {
|
|
|
802
909
|
*
|
|
803
910
|
* @internal Exported for integration testing only — not part of the public CLI API.
|
|
804
911
|
*/
|
|
805
|
-
export async function runCompatAnalysis(results, logger, effectiveSettings, vatContext = null) {
|
|
912
|
+
export async function runCompatAnalysis(results, logger, locationRoot, effectiveSettings, vatContext = null) {
|
|
806
913
|
const compatMap = new Map();
|
|
807
914
|
for (const result of results) {
|
|
808
915
|
if (result.type !== RESOURCE_TYPE_CLAUDE_PLUGIN)
|
|
@@ -813,7 +920,7 @@ export async function runCompatAnalysis(results, logger, effectiveSettings, vatC
|
|
|
813
920
|
const analyzeOptions = configTargets === undefined
|
|
814
921
|
? undefined
|
|
815
922
|
: { configTargets };
|
|
816
|
-
const compat = await analyzeCompatibility(result.path, analyzeOptions);
|
|
923
|
+
const compat = await analyzeCompatibility(result.path, locationRoot, analyzeOptions);
|
|
817
924
|
// Settings conflict detection (when --settings flag is used)
|
|
818
925
|
if (effectiveSettings === undefined) {
|
|
819
926
|
compatMap.set(result.path, compat);
|
|
@@ -893,12 +1000,23 @@ function applyVerboseFilter(results, verbose) {
|
|
|
893
1000
|
return stripped;
|
|
894
1001
|
});
|
|
895
1002
|
}
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
1003
|
+
/**
|
|
1004
|
+
* Nested carriers inside a file entry that publish their own `path`.
|
|
1005
|
+
*
|
|
1006
|
+
* `linkedFiles[].path` sat absolute in every document while every sibling
|
|
1007
|
+
* `path` and `location` beside it was root-relative — 532 of them in a single
|
|
1008
|
+
* `vat audit --user` run. Naming the carrier here is what re-bases it; a new
|
|
1009
|
+
* carrier that is not named keeps shipping absolute paths.
|
|
1010
|
+
*/
|
|
1011
|
+
const NESTED_PATH_CARRIERS = ['linkedFiles'];
|
|
1012
|
+
function calculateSummary(results, startTime, compatMap, verbose, root) {
|
|
1013
|
+
const { entries, ...base } = buildBaseSummary(results, startTime);
|
|
1014
|
+
const withCompat = applyCompatMap(entries, compatMap);
|
|
899
1015
|
return {
|
|
1016
|
+
// Stated once, first, and the only absolute path in the document.
|
|
1017
|
+
root,
|
|
900
1018
|
...base,
|
|
901
|
-
files: applyVerboseFilter(withCompat, verbose),
|
|
1019
|
+
files: relativizePathEntries(applyVerboseFilter(withCompat, verbose), root, NESTED_PATH_CARRIERS),
|
|
902
1020
|
};
|
|
903
1021
|
}
|
|
904
1022
|
/**
|
|
@@ -948,7 +1066,7 @@ function renderIssueEvidence(issue, evidenceSources, logger) {
|
|
|
948
1066
|
* result itself (per-file SKILL evidence) and from any attached
|
|
949
1067
|
* `compatibility.evidence` (plugin-level scanner output).
|
|
950
1068
|
*/
|
|
951
|
-
function renderVerboseEvidence(results, logger) {
|
|
1069
|
+
function renderVerboseEvidence(results, root, logger) {
|
|
952
1070
|
for (const result of results) {
|
|
953
1071
|
const capabilityIssues = (result.issues ?? []).filter((i) => i.code.startsWith('CAPABILITY_'));
|
|
954
1072
|
if (capabilityIssues.length === 0)
|
|
@@ -959,14 +1077,14 @@ function renderVerboseEvidence(results, logger) {
|
|
|
959
1077
|
];
|
|
960
1078
|
if (evidenceSources.length === 0)
|
|
961
1079
|
continue;
|
|
962
|
-
logger.info(`\n${result.path} — supporting evidence:`);
|
|
1080
|
+
logger.info(`\n${issueLocation(result.path, root)} — supporting evidence:`);
|
|
963
1081
|
for (const issue of capabilityIssues) {
|
|
964
1082
|
renderIssueEvidence(issue, evidenceSources, logger);
|
|
965
1083
|
}
|
|
966
1084
|
}
|
|
967
1085
|
}
|
|
968
|
-
function handleAuditResults(results, summary, logger) {
|
|
969
|
-
const {
|
|
1086
|
+
function handleAuditResults(results, summary, logger, verbose) {
|
|
1087
|
+
const { filesWithErrors: errorCount, filesWithWarnings: warningCount, filesPassed: successCount, } = summary.summary;
|
|
970
1088
|
// Report settings conflicts (advisory, non-blocking)
|
|
971
1089
|
const totalSettingsConflicts = (summary.files ?? []).reduce((sum, f) => {
|
|
972
1090
|
return sum + (f.compatibility?.settingsConflicts?.length ?? 0);
|
|
@@ -977,13 +1095,20 @@ function handleAuditResults(results, summary, logger) {
|
|
|
977
1095
|
// Audit is advisory only — always exit 0 for validation results.
|
|
978
1096
|
// Use vat skills validate for gated validation (exit 1 on errors).
|
|
979
1097
|
if (errorCount > 0) {
|
|
980
|
-
|
|
1098
|
+
logger.error(`Audit failed: ${errorCount} file(s) with errors`);
|
|
1099
|
+
logFindingsForStatus(results, 'error', summary.root, logger.error.bind(logger), verbose);
|
|
981
1100
|
}
|
|
982
1101
|
else if (warningCount > 0) {
|
|
983
|
-
|
|
1102
|
+
logger.info(`Audit passed with warnings: ${warningCount} file(s)`);
|
|
1103
|
+
logFindingsForStatus(results, 'warning', summary.root, logger.info.bind(logger), verbose);
|
|
984
1104
|
}
|
|
985
1105
|
else {
|
|
986
|
-
|
|
1106
|
+
// `success` means "nothing actionable", NOT "nothing to see" — name the
|
|
1107
|
+
// informational findings or the YAML and the stderr line disagree.
|
|
1108
|
+
const withFindings = countResultsWithFindings(results);
|
|
1109
|
+
logger.info(withFindings > 0
|
|
1110
|
+
? `Audit successful: ${successCount} file(s) passed (${withFindings} with informational findings)`
|
|
1111
|
+
: `Audit successful: ${successCount} file(s) passed`);
|
|
987
1112
|
}
|
|
988
1113
|
renderAuditFooter(results, logger);
|
|
989
1114
|
process.exit(0);
|
|
@@ -1007,33 +1132,62 @@ function renderAuditFooter(results, logger) {
|
|
|
1007
1132
|
}
|
|
1008
1133
|
renderSkillQualityFooter(logger, hasSkillFindings, emittedCodes);
|
|
1009
1134
|
}
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1135
|
+
/**
|
|
1136
|
+
* The stderr findings report for a set of audit results — the HUMAN channel.
|
|
1137
|
+
*
|
|
1138
|
+
* Deliberately NOT the YAML document. `vat audit` publishes a machine-readable
|
|
1139
|
+
* report on stdout that CI and tooling parse, and that document carries every
|
|
1140
|
+
* finding at every verbosity; this stream is read by a person, and on a 90-skill
|
|
1141
|
+
* corpus it printed 26,480 lines, 3,480 of them occurrences of a single
|
|
1142
|
+
* high-cardinality warning code. The two channels answer different questions, so
|
|
1143
|
+
* only this one collapses.
|
|
1144
|
+
*
|
|
1145
|
+
* Which findings render in full is not decided here — it is
|
|
1146
|
+
* {@link issuesToRenderAtVerbosity}, shared with the other findings-reporting
|
|
1147
|
+
* verbs so `vat audit`, `vat skills validate` and `vat skills build` cannot drift
|
|
1148
|
+
* onto three answers again. Errors always render; warnings and info collapse
|
|
1149
|
+
* unless `verbose`; allow-listed findings never render.
|
|
1150
|
+
*
|
|
1151
|
+
* The severity breakdown on each file's heading is load-bearing, not decoration.
|
|
1152
|
+
* For a warning-only file at default verbosity that heading is the ENTIRE report
|
|
1153
|
+
* for that file, so dropping the counts would turn the collapse into silence —
|
|
1154
|
+
* the failure direction `issue-rendering.ts` exists to prevent. The trailing
|
|
1155
|
+
* line names the collapsed total and both ways to see it, for the same reason.
|
|
1156
|
+
*
|
|
1157
|
+
* Pure: takes results, returns lines. It reads `issues` and never rewrites it,
|
|
1158
|
+
* because `runAuditAtPath` builds the YAML document from the very same array.
|
|
1159
|
+
*
|
|
1160
|
+
* @internal Exported for unit testing only — not part of the public CLI API.
|
|
1161
|
+
*/
|
|
1162
|
+
export function formatAuditFindingsLines(results, root, verbose) {
|
|
1163
|
+
const lines = [];
|
|
1164
|
+
let collapsed = 0;
|
|
1165
|
+
for (const result of results) {
|
|
1166
|
+
const rendered = issuesToRenderAtVerbosity(result.issues, verbose);
|
|
1167
|
+
// What --verbose WOULD show, minus what this verbosity shows. Derived from
|
|
1168
|
+
// the policy rather than re-stating it, so the count cannot contradict it.
|
|
1169
|
+
collapsed += issuesToRenderAtVerbosity(result.issues, true).length - rendered.length;
|
|
1170
|
+
lines.push(`\n${issueLocation(result.path, root)} — ${formatSeverityBreakdown(countBySeverity(result.issues))}:`);
|
|
1171
|
+
for (const issue of rendered) {
|
|
1172
|
+
lines.push(...formatIssueLines(issue, ' '));
|
|
1173
|
+
}
|
|
1017
1174
|
}
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
const warningResults = results.filter((r) => r.status === 'warning');
|
|
1022
|
-
for (const result of warningResults) {
|
|
1023
|
-
logger.info(`\n${result.path}:`);
|
|
1024
|
-
const warningIssues = result.issues.filter((i) => i.severity === 'warning');
|
|
1025
|
-
logIssues(warningIssues, logger.info.bind(logger));
|
|
1175
|
+
if (collapsed > 0) {
|
|
1176
|
+
lines.push(`\n${collapsed} warning/info finding(s) not shown — re-run with --verbose, ` +
|
|
1177
|
+
'or read the YAML report on stdout, which always lists every finding.');
|
|
1026
1178
|
}
|
|
1179
|
+
return lines;
|
|
1027
1180
|
}
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1181
|
+
/**
|
|
1182
|
+
* Write one status class's findings to stderr beneath the run banner.
|
|
1183
|
+
*
|
|
1184
|
+
* The banner counts FILES and the blocks below count FINDINGS; both are needed,
|
|
1185
|
+
* and `logFn` picks the channel the calling branch already established.
|
|
1186
|
+
*/
|
|
1187
|
+
function logFindingsForStatus(results, status, root, logFn, verbose) {
|
|
1188
|
+
const selected = results.filter((r) => r.status === status);
|
|
1189
|
+
for (const line of formatAuditFindingsLines(selected, root, verbose)) {
|
|
1190
|
+
logFn(line);
|
|
1037
1191
|
}
|
|
1038
1192
|
}
|
|
1039
1193
|
/**
|
|
@@ -1051,10 +1205,11 @@ function isExcludedPath(isMatch, relativePath, isDirectory) {
|
|
|
1051
1205
|
* Handle file entry during directory scan
|
|
1052
1206
|
*/
|
|
1053
1207
|
async function handleFileEntry(entry, fullPath, options, logger, scanCtx) {
|
|
1208
|
+
const { locationRoot } = scanCtx;
|
|
1054
1209
|
// Check for registry files
|
|
1055
1210
|
if (entry.name === 'installed_plugins.json' || entry.name === 'known_marketplaces.json') {
|
|
1056
1211
|
logger.debug(`Validating registry: ${fullPath}`);
|
|
1057
|
-
return validate(fullPath);
|
|
1212
|
+
return validate(fullPath, { locationRoot });
|
|
1058
1213
|
}
|
|
1059
1214
|
// Check for SKILL.md
|
|
1060
1215
|
if (entry.name === 'SKILL.md') {
|
|
@@ -1068,12 +1223,17 @@ async function handleFileEntry(entry, fullPath, options, logger, scanCtx) {
|
|
|
1068
1223
|
logger.debug(` Using config-aware validation for: ${fullPath}`);
|
|
1069
1224
|
// Thread the per-scan tracker into packaging validation so gitignore
|
|
1070
1225
|
// checks in the link-graph walk stay O(1).
|
|
1071
|
-
|
|
1226
|
+
// `projectSkills`: same project-wide test-input rule as the lane above,
|
|
1227
|
+
// resolved through the same per-config-root memo.
|
|
1228
|
+
const projectSkills = await resolveProjectDeclaredEvalSuites(fullPath);
|
|
1229
|
+
const sharedCtx = scanCtx.gitTracker === null
|
|
1230
|
+
? { locationRoot, projectSkills }
|
|
1231
|
+
: { gitTracker: scanCtx.gitTracker, locationRoot, projectSkills };
|
|
1072
1232
|
const packagingResult = await validateSkillForPackaging(fullPath, skillConfig, 'source', sharedCtx);
|
|
1073
|
-
return packagingResultToValidationResult(fullPath, packagingResult, skillConfig.targets);
|
|
1233
|
+
return packagingResultToValidationResult(fullPath, packagingResult, skillConfig.targets, locationRoot);
|
|
1074
1234
|
}
|
|
1075
1235
|
// Wild mode: basic validation
|
|
1076
|
-
const validateOptions = { skillPath: fullPath };
|
|
1236
|
+
const validateOptions = { skillPath: fullPath, locationRoot };
|
|
1077
1237
|
if (options.warnUnreferencedFiles) {
|
|
1078
1238
|
validateOptions.checkUnreferencedFiles = true;
|
|
1079
1239
|
}
|
|
@@ -1086,26 +1246,120 @@ async function handleFileEntry(entry, fullPath, options, logger, scanCtx) {
|
|
|
1086
1246
|
*/
|
|
1087
1247
|
async function handleDirectoryEntry(fullPath, recursive, options, logger, baseDir, scanCtx, nestedConfigLog) {
|
|
1088
1248
|
const fs = await import('node:fs/promises');
|
|
1249
|
+
const { locationRoot } = scanCtx;
|
|
1089
1250
|
const results = [];
|
|
1090
1251
|
// Check if directory contains a plugin or marketplace
|
|
1091
1252
|
const claudePluginDir = safePath.join(fullPath, '.claude-plugin');
|
|
1092
1253
|
const hasClaudePlugin = await fs.access(claudePluginDir).then(() => true).catch(() => false);
|
|
1093
1254
|
if (hasClaudePlugin) {
|
|
1094
1255
|
logger.debug(`Validating resource directory: ${fullPath}`);
|
|
1095
|
-
const result = await validate(fullPath, { validatePlugin });
|
|
1096
|
-
const inv = await
|
|
1097
|
-
appendInventoryParseErrors(result, inv);
|
|
1256
|
+
const result = await validate(fullPath, { validatePlugin, locationRoot });
|
|
1257
|
+
const inv = await pluginInventoryAt(fullPath);
|
|
1258
|
+
appendInventoryParseErrors(result, inv, locationRoot);
|
|
1098
1259
|
results.push(result);
|
|
1099
1260
|
}
|
|
1100
1261
|
// Recurse into subdirectories (both plugin/marketplace dirs and regular dirs)
|
|
1101
1262
|
if (recursive) {
|
|
1102
|
-
const subResults = await scanDirectory(fullPath, recursive, options, logger,
|
|
1263
|
+
const subResults = await scanDirectory(fullPath, recursive, options, logger, scanCtx, baseDir, nestedConfigLog);
|
|
1103
1264
|
results.push(...subResults);
|
|
1104
1265
|
}
|
|
1105
1266
|
return results;
|
|
1106
1267
|
}
|
|
1268
|
+
/**
|
|
1269
|
+
* Compile the governing project's `resources.exclude` for a scan.
|
|
1270
|
+
*
|
|
1271
|
+
* The config is found by walking UP from the scan directory ({@link findProjectRoot}),
|
|
1272
|
+
* not by looking in it. Looking only in the scan directory is how a path argument
|
|
1273
|
+
* silently voided every exclude the project had declared: `vat audit
|
|
1274
|
+
* packages/x/resources/skills/` found no config there, so a package that excludes
|
|
1275
|
+
* its deliberately-broken eval fixtures had them audited as production skills the
|
|
1276
|
+
* moment anyone named a subdirectory. Commit 8a466b0c fixed the same defect for
|
|
1277
|
+
* `vat resources validate|scan` and `vat rag index`; this is the lane it missed.
|
|
1278
|
+
* The rule is the same one that commit established: a path argument says WHICH
|
|
1279
|
+
* tree to audit, and `exclude` applies either way.
|
|
1280
|
+
*
|
|
1281
|
+
* With one deliberate exception, mirroring the gitignore rule in
|
|
1282
|
+
* {@link resolveScanContext}: when the operator points the scan AT an excluded
|
|
1283
|
+
* tree, their explicit intent wins and the excludes are dropped for that run.
|
|
1284
|
+
* Otherwise naming an excluded directory would report `filesScanned: 0,
|
|
1285
|
+
* status: success` — a green run that scanned nothing.
|
|
1286
|
+
*/
|
|
1287
|
+
function resolveProjectExcludes(scanDir, logger) {
|
|
1288
|
+
const projectRoot = findProjectRoot(scanDir);
|
|
1289
|
+
if (projectRoot === null)
|
|
1290
|
+
return null;
|
|
1291
|
+
let patterns;
|
|
1292
|
+
try {
|
|
1293
|
+
patterns = loadConfig(projectRoot)?.resources?.exclude ?? [];
|
|
1294
|
+
}
|
|
1295
|
+
catch (err) {
|
|
1296
|
+
// Audit is a bulk linter over trees it does not own; a broken governing
|
|
1297
|
+
// config must not abort the scan. Say so rather than dropping it silently.
|
|
1298
|
+
logger.debug(`Ignoring unreadable config at ${projectRoot}: ${String(err)}`);
|
|
1299
|
+
return null;
|
|
1300
|
+
}
|
|
1301
|
+
if (patterns.length === 0)
|
|
1302
|
+
return null;
|
|
1303
|
+
// dot:true so excludes like `**/.cache/*` match through dotfile dirs;
|
|
1304
|
+
// without it the exclude silently never fires.
|
|
1305
|
+
const isMatch = picomatch([...patterns], { dot: true });
|
|
1306
|
+
const scanRelToProject = safePath.relative(projectRoot, safePath.resolve(scanDir)).replaceAll('\\', '/');
|
|
1307
|
+
if (scanRelToProject !== '' && isExcludedPath(isMatch, scanRelToProject, true)) {
|
|
1308
|
+
logger.debug(`Scan root ${scanRelToProject} is excluded by the config at ${projectRoot}; ` +
|
|
1309
|
+
'auditing it anyway because it was named explicitly');
|
|
1310
|
+
return null;
|
|
1311
|
+
}
|
|
1312
|
+
return { isMatch, base: projectRoot, label: 'excluded by config' };
|
|
1313
|
+
}
|
|
1107
1314
|
/** Cache of (gitRoot → initialized GitTracker) to avoid re-spawning ls-files. */
|
|
1108
1315
|
const gitTrackerCache = new Map();
|
|
1316
|
+
/**
|
|
1317
|
+
* Cache of (projectRoot → registry for the *inventory* link walk).
|
|
1318
|
+
*
|
|
1319
|
+
* Deliberately separate from the validator's own registry memo (which now lives
|
|
1320
|
+
* beside `crawlAndResolveRegistry`, so this lane no longer keeps a second copy of
|
|
1321
|
+
* that cache): the validator's registry crawls markdown AND HTML, while the
|
|
1322
|
+
* inventory walk has always been markdown-only. Sharing one registry between them
|
|
1323
|
+
* would quietly widen the link graph `files.linked` is computed from, which is a
|
|
1324
|
+
* behaviour change and not this fix's business. Two crawls per project root is
|
|
1325
|
+
* still two, not two per skill.
|
|
1326
|
+
*/
|
|
1327
|
+
const inventoryRegistryCache = new Map();
|
|
1328
|
+
/** Build (once per run, per root) the registry the plugin inventory link walk needs. */
|
|
1329
|
+
async function getOrCreateInventoryRegistry(projectRoot) {
|
|
1330
|
+
const cached = inventoryRegistryCache.get(projectRoot);
|
|
1331
|
+
if (cached !== undefined) {
|
|
1332
|
+
return cached;
|
|
1333
|
+
}
|
|
1334
|
+
const pending = crawlSkillLinkRegistry(projectRoot);
|
|
1335
|
+
inventoryRegistryCache.set(projectRoot, pending);
|
|
1336
|
+
return pending;
|
|
1337
|
+
}
|
|
1338
|
+
/**
|
|
1339
|
+
* `extractClaudePluginInventory` for a plugin directory, handing it the shared
|
|
1340
|
+
* inventory registry so each skill it walks does not re-parse the corpus.
|
|
1341
|
+
*
|
|
1342
|
+
* A PROVIDER, not an awaited registry, and only when there is a project root — the same
|
|
1343
|
+
* two rules `vat inventory`'s `linkRegistryProviderFor` follows, for the same two reasons:
|
|
1344
|
+
*
|
|
1345
|
+
* 1. **Unresolved.** `extractClaudePluginInventory` promises "never throws — all failures
|
|
1346
|
+
* surface via parseErrors[]", and that promise holds only while the crawl runs inside
|
|
1347
|
+
* `walkLinkedFiles`'s try/catch. Awaiting it here moved the crawl outside that catch,
|
|
1348
|
+
* so one unreadable markdown file anywhere under the root aborted the WHOLE audit with
|
|
1349
|
+
* `status: error` and exit code 2 instead of degrading three link walks. The extractor
|
|
1350
|
+
* memoizes the provider and caches even a rejection, so the failed crawl is still
|
|
1351
|
+
* attempted once, not once per skill.
|
|
1352
|
+
* 2. **Only a project root.** Reuse is gated on exact `registry.baseDir === projectRoot`
|
|
1353
|
+
* equality against the root `walkLinkedFiles` derives per skill
|
|
1354
|
+
* (`findProjectRoot(dirname(skillMd)) ?? dirname(skillMd)`). With no project root that
|
|
1355
|
+
* per-skill fallback is each skill's OWN directory, so a registry rooted at the plugin
|
|
1356
|
+
* matches nothing: it would be crawled, compared, discarded, and re-crawled per skill.
|
|
1357
|
+
* No root, no provider.
|
|
1358
|
+
*/
|
|
1359
|
+
async function pluginInventoryAt(dir) {
|
|
1360
|
+
const projectRoot = findProjectRoot(dir);
|
|
1361
|
+
return extractClaudePluginInventory(dir, projectRoot === null ? undefined : () => getOrCreateInventoryRegistry(projectRoot));
|
|
1362
|
+
}
|
|
1109
1363
|
/**
|
|
1110
1364
|
* Reset all module-level audit caches. Must be called at the start of every
|
|
1111
1365
|
* independent audit invocation so in-process callers (CLI entrypoint AND
|
|
@@ -1114,6 +1368,8 @@ const gitTrackerCache = new Map();
|
|
|
1114
1368
|
*/
|
|
1115
1369
|
export function resetAuditCaches() {
|
|
1116
1370
|
gitTrackerCache.clear();
|
|
1371
|
+
resetPackagingRegistryCache();
|
|
1372
|
+
inventoryRegistryCache.clear();
|
|
1117
1373
|
resetProjectRootCaches();
|
|
1118
1374
|
resetLoadedConfigCache();
|
|
1119
1375
|
resetSkillDiscoveryCache();
|
|
@@ -1138,18 +1394,19 @@ async function getOrCreateGitTracker(gitRoot) {
|
|
|
1138
1394
|
* `dist/`), returns `{ gitRoot: null, gitTracker: null }` to disable gitignore
|
|
1139
1395
|
* filtering — the user's explicit intent takes precedence over .gitignore.
|
|
1140
1396
|
*/
|
|
1141
|
-
async function resolveScanContext(dirPath) {
|
|
1397
|
+
async function resolveScanContext(dirPath, locationRoot, logger) {
|
|
1398
|
+
const projectExcludes = resolveProjectExcludes(dirPath, logger);
|
|
1142
1399
|
const detectedRoot = gitFindRoot(dirPath);
|
|
1143
1400
|
if (detectedRoot === null) {
|
|
1144
|
-
return { gitRoot: null, gitTracker: null };
|
|
1401
|
+
return { gitRoot: null, gitTracker: null, locationRoot, projectExcludes };
|
|
1145
1402
|
}
|
|
1146
1403
|
const tracker = await getOrCreateGitTracker(detectedRoot);
|
|
1147
1404
|
// If the scan root itself is gitignored (e.g. user targeted `dist/`), disable
|
|
1148
1405
|
// gitignore filtering so the user's explicit intent wins.
|
|
1149
1406
|
if (tracker.isIgnoredByActiveSet(dirPath)) {
|
|
1150
|
-
return { gitRoot: null, gitTracker: null };
|
|
1407
|
+
return { gitRoot: null, gitTracker: null, locationRoot, projectExcludes };
|
|
1151
1408
|
}
|
|
1152
|
-
return { gitRoot: detectedRoot, gitTracker: tracker };
|
|
1409
|
+
return { gitRoot: detectedRoot, gitTracker: tracker, locationRoot, projectExcludes };
|
|
1153
1410
|
}
|
|
1154
1411
|
/**
|
|
1155
1412
|
* Build the gitignore exclusion map for a set of entry paths.
|
|
@@ -1170,7 +1427,7 @@ function buildGitIgnoreMap(entryPaths, gitTracker, includeArtifacts) {
|
|
|
1170
1427
|
* Check whether a directory entry should be skipped during scanning.
|
|
1171
1428
|
* Returns a reason string for debug logging, or null if the entry should be kept.
|
|
1172
1429
|
*/
|
|
1173
|
-
function getSkipReason(entry, fullPath,
|
|
1430
|
+
function getSkipReason(entry, fullPath, gitIgnoredMap, excludeMatchers) {
|
|
1174
1431
|
// Always skip .git directory (not reported by git check-ignore)
|
|
1175
1432
|
if (entry.isDirectory() && entry.name === '.git') {
|
|
1176
1433
|
return '.git';
|
|
@@ -1179,23 +1436,23 @@ function getSkipReason(entry, fullPath, resolvedBaseDir, gitIgnoredMap, isMatch)
|
|
|
1179
1436
|
if (gitIgnoredMap !== null && gitIgnoredMap.get(fullPath) === true) {
|
|
1180
1437
|
return `gitignored: ${entry.name}`;
|
|
1181
1438
|
}
|
|
1182
|
-
//
|
|
1183
|
-
|
|
1184
|
-
const relativePath = safePath.relative(
|
|
1185
|
-
if (isExcludedPath(isMatch, relativePath, entry.isDirectory())) {
|
|
1186
|
-
return
|
|
1439
|
+
// Each matcher answers against ITS OWN base — see {@link ExcludeMatcher}.
|
|
1440
|
+
for (const matcher of excludeMatchers) {
|
|
1441
|
+
const relativePath = safePath.relative(matcher.base, fullPath).replaceAll('\\', '/');
|
|
1442
|
+
if (isExcludedPath(matcher.isMatch, relativePath, entry.isDirectory())) {
|
|
1443
|
+
return `${matcher.label}: ${relativePath}`;
|
|
1187
1444
|
}
|
|
1188
1445
|
}
|
|
1189
1446
|
return null;
|
|
1190
1447
|
}
|
|
1191
|
-
async function scanDirectory(dirPath, recursive, options, logger,
|
|
1448
|
+
async function scanDirectory(dirPath, recursive, options, logger, scanCtx, baseDir, nestedConfigLog) {
|
|
1192
1449
|
const fs = await import('node:fs/promises');
|
|
1193
1450
|
const results = [];
|
|
1194
1451
|
const userExcludes = options.exclude ?? [];
|
|
1195
1452
|
const resolvedBaseDir = baseDir ?? dirPath;
|
|
1196
|
-
//
|
|
1197
|
-
//
|
|
1198
|
-
const resolvedScanCtx = scanCtx
|
|
1453
|
+
// The git context (root + tracker) and the run's anchor base are built once by
|
|
1454
|
+
// the caller; every recursion reuses them unchanged.
|
|
1455
|
+
const resolvedScanCtx = scanCtx;
|
|
1199
1456
|
const resolvedNestedLog = nestedConfigLog ?? new Set();
|
|
1200
1457
|
// Top-down pre-warm of findProjectRoot's walk-up cache (spec §8). The first
|
|
1201
1458
|
// call here drives a single ancestor walk; every subsequent recursion is a
|
|
@@ -1216,14 +1473,26 @@ async function scanDirectory(dirPath, recursive, options, logger, baseDir, scanC
|
|
|
1216
1473
|
// Compile picomatch for user-supplied --exclude patterns.
|
|
1217
1474
|
// dot:true so excludes like `**/private/*` match through dotfile dirs
|
|
1218
1475
|
// (`.claude/.../private/x`); without it the exclude silently never fires.
|
|
1219
|
-
|
|
1476
|
+
// These are relative to the scan base; the config's excludes carry their own
|
|
1477
|
+
// (project-root) base on the matcher — see {@link ExcludeMatcher}.
|
|
1478
|
+
const excludeMatchers = [];
|
|
1479
|
+
if (userExcludes.length > 0) {
|
|
1480
|
+
excludeMatchers.push({
|
|
1481
|
+
isMatch: picomatch(userExcludes, { dot: true }),
|
|
1482
|
+
base: resolvedBaseDir,
|
|
1483
|
+
label: 'excluded',
|
|
1484
|
+
});
|
|
1485
|
+
}
|
|
1486
|
+
if (resolvedScanCtx.projectExcludes !== null) {
|
|
1487
|
+
excludeMatchers.push(resolvedScanCtx.projectExcludes);
|
|
1488
|
+
}
|
|
1220
1489
|
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
|
1221
1490
|
// Build full paths and batch-check gitignore status
|
|
1222
1491
|
const entryPaths = entries.map(e => safePath.join(dirPath, e.name));
|
|
1223
1492
|
const gitIgnoredMap = buildGitIgnoreMap(entryPaths, resolvedScanCtx.gitTracker, options.includeArtifacts ?? false);
|
|
1224
1493
|
for (const entry of entries) {
|
|
1225
1494
|
const fullPath = safePath.join(dirPath, entry.name);
|
|
1226
|
-
const skipReason = getSkipReason(entry, fullPath,
|
|
1495
|
+
const skipReason = getSkipReason(entry, fullPath, gitIgnoredMap, excludeMatchers);
|
|
1227
1496
|
if (skipReason !== null) {
|
|
1228
1497
|
logger.debug(`Excluding path: ${skipReason}`);
|
|
1229
1498
|
continue;
|
|
@@ -1256,76 +1525,76 @@ function calculateOverallStatus(results) {
|
|
|
1256
1525
|
return 'success';
|
|
1257
1526
|
}
|
|
1258
1527
|
/**
|
|
1259
|
-
*
|
|
1528
|
+
* How many results carry at least one finding.
|
|
1529
|
+
*
|
|
1530
|
+
* Counted from the results, not from the rendered hierarchy. The hierarchy is a
|
|
1531
|
+
* PROJECTION of the results — in `--verbose` it contains every scanned skill,
|
|
1532
|
+
* findings or not, so counting its entries reported "N with issues" equal to the
|
|
1533
|
+
* number scanned. The predicate itself is the only thing that answers this.
|
|
1260
1534
|
*/
|
|
1261
|
-
function
|
|
1262
|
-
|
|
1263
|
-
// Count marketplace skills
|
|
1264
|
-
for (const marketplace of hierarchical.marketplaces) {
|
|
1265
|
-
for (const plugin of marketplace.plugins) {
|
|
1266
|
-
total += plugin.skills.length;
|
|
1267
|
-
}
|
|
1268
|
-
}
|
|
1269
|
-
// Count cached plugin skills
|
|
1270
|
-
for (const plugin of hierarchical.cachedPlugins) {
|
|
1271
|
-
total += plugin.skills.length;
|
|
1272
|
-
}
|
|
1273
|
-
// Count standalone plugin skills
|
|
1274
|
-
for (const plugin of hierarchical.standalonePlugins) {
|
|
1275
|
-
total += plugin.skills.length;
|
|
1276
|
-
}
|
|
1277
|
-
// Count standalone skills
|
|
1278
|
-
total += hierarchical.standaloneSkills.length;
|
|
1279
|
-
return total;
|
|
1535
|
+
function countResultsWithFindings(results) {
|
|
1536
|
+
return results.filter((r) => r.issues.length > 0).length;
|
|
1280
1537
|
}
|
|
1281
|
-
/**
|
|
1282
|
-
|
|
1283
|
-
*/
|
|
1284
|
-
function calculateIssueCounts(results) {
|
|
1285
|
-
const successCount = results.filter((r) => r.status === 'success').length;
|
|
1286
|
-
const warningCount = results.filter((r) => r.status === 'warning').length;
|
|
1287
|
-
const errorCount = results.filter((r) => r.status === 'error').length;
|
|
1288
|
-
const totalErrors = results.reduce((sum, r) => sum + r.issues.filter(i => i.severity === 'error').length, 0);
|
|
1289
|
-
const totalWarnings = results.reduce((sum, r) => sum + r.issues.filter(i => i.severity === 'warning').length, 0);
|
|
1290
|
-
const totalInfo = results.reduce((sum, r) => sum + r.issues.filter(i => i.severity === 'info').length, 0);
|
|
1538
|
+
/** Count files by their own status. */
|
|
1539
|
+
function countFilesByStatus(results) {
|
|
1291
1540
|
return {
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
totalWarnings,
|
|
1297
|
-
totalInfo,
|
|
1541
|
+
filesScanned: results.length,
|
|
1542
|
+
filesPassed: results.filter((r) => r.status === 'success').length,
|
|
1543
|
+
filesWithWarnings: results.filter((r) => r.status === 'warning').length,
|
|
1544
|
+
filesWithErrors: results.filter((r) => r.status === 'error').length,
|
|
1298
1545
|
};
|
|
1299
1546
|
}
|
|
1300
1547
|
/**
|
|
1301
|
-
* Build base summary structure (used by both flat and hierarchical)
|
|
1548
|
+
* Build base summary structure (used by both flat and hierarchical), together
|
|
1549
|
+
* with the entries the document will publish.
|
|
1550
|
+
*
|
|
1551
|
+
* `issueCounts` is named exactly as it is on every individual file entry, and
|
|
1552
|
+
* means the same thing at both levels: findings, by severity. `summary` only ever
|
|
1553
|
+
* counts files.
|
|
1554
|
+
*
|
|
1555
|
+
* The header total and every per-file total come from ONE traversal of the
|
|
1556
|
+
* final issue set, and the entries carrying those per-file totals are returned
|
|
1557
|
+
* from the same pass. Before that, the header was derived from the issue
|
|
1558
|
+
* records while each file's `issueCounts` was whatever its producer had
|
|
1559
|
+
* published — and a producer that appends findings after publishing (or never
|
|
1560
|
+
* sets the counts at all: `validatePlugin` writes `status` and `summary` but
|
|
1561
|
+
* not `issueCounts`) left the two disagreeing inside one document. A real
|
|
1562
|
+
* `vat audit --user` run said 55/422/504 in the header over per-file counts
|
|
1563
|
+
* summing to 55/360/405, with 91 of 614 entries declaring `{0,0,0}` above the
|
|
1564
|
+
* findings they listed. Re-deriving here means the header and the sum are the
|
|
1565
|
+
* same arithmetic on the same array, so they cannot drift again no matter what
|
|
1566
|
+
* a producer forgets.
|
|
1567
|
+
*
|
|
1568
|
+
* `linkedFiles[].issues` are deliberately NOT added: every linked finding is
|
|
1569
|
+
* also present in its owner's `issues`, so the nested list is a VIEW of records
|
|
1570
|
+
* already counted, not extra records. `audit-report-coherence.integration.test.ts`
|
|
1571
|
+
* pins that subset relation, because counting once is only correct while it holds.
|
|
1302
1572
|
*/
|
|
1303
1573
|
function buildBaseSummary(results, startTime) {
|
|
1304
|
-
const
|
|
1305
|
-
const
|
|
1574
|
+
const issueCounts = { errors: 0, warnings: 0, info: 0 };
|
|
1575
|
+
const entries = results.map((result) => {
|
|
1576
|
+
const counts = countBySeverity(result.issues);
|
|
1577
|
+
issueCounts.errors += counts.errors;
|
|
1578
|
+
issueCounts.warnings += counts.warnings;
|
|
1579
|
+
issueCounts.info += counts.info;
|
|
1580
|
+
return { ...result, issueCounts: counts };
|
|
1581
|
+
});
|
|
1306
1582
|
return {
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
warnings: counts.warningCount,
|
|
1312
|
-
errors: counts.errorCount,
|
|
1313
|
-
},
|
|
1314
|
-
issues: {
|
|
1315
|
-
errors: counts.totalErrors,
|
|
1316
|
-
warnings: counts.totalWarnings,
|
|
1317
|
-
info: counts.totalInfo,
|
|
1318
|
-
},
|
|
1583
|
+
entries,
|
|
1584
|
+
status: calculateOverallStatus(entries),
|
|
1585
|
+
summary: countFilesByStatus(entries),
|
|
1586
|
+
issueCounts,
|
|
1319
1587
|
duration: `${Date.now() - startTime}ms`,
|
|
1320
1588
|
};
|
|
1321
1589
|
}
|
|
1322
1590
|
/**
|
|
1323
1591
|
* Calculate summary for hierarchical output
|
|
1324
1592
|
*/
|
|
1325
|
-
function calculateHierarchicalSummary(results, hierarchical, startTime, compatMap, verbose) {
|
|
1326
|
-
const base = buildBaseSummary(results, startTime);
|
|
1327
|
-
const withCompat = applyCompatMap(
|
|
1593
|
+
function calculateHierarchicalSummary(results, hierarchical, startTime, compatMap, verbose, root) {
|
|
1594
|
+
const { entries, ...base } = buildBaseSummary(results, startTime);
|
|
1595
|
+
const withCompat = applyCompatMap(entries, compatMap);
|
|
1328
1596
|
return {
|
|
1597
|
+
root,
|
|
1329
1598
|
...base,
|
|
1330
1599
|
summary: {
|
|
1331
1600
|
...base.summary,
|
|
@@ -1334,16 +1603,16 @@ function calculateHierarchicalSummary(results, hierarchical, startTime, compatMa
|
|
|
1334
1603
|
standalonePlugins: hierarchical.standalonePlugins.length,
|
|
1335
1604
|
standaloneSkills: hierarchical.standaloneSkills.length,
|
|
1336
1605
|
},
|
|
1337
|
-
files: applyVerboseFilter(withCompat, verbose),
|
|
1606
|
+
files: relativizePathEntries(applyVerboseFilter(withCompat, verbose), root, NESTED_PATH_CARRIERS),
|
|
1338
1607
|
hierarchical,
|
|
1339
1608
|
};
|
|
1340
1609
|
}
|
|
1341
1610
|
/**
|
|
1342
1611
|
* Log hierarchical summary to stderr
|
|
1343
1612
|
*/
|
|
1344
|
-
function logHierarchicalSummary(results,
|
|
1613
|
+
function logHierarchicalSummary(results, logger) {
|
|
1345
1614
|
const status = calculateOverallStatus(results);
|
|
1346
|
-
const skillsWithIssues =
|
|
1615
|
+
const skillsWithIssues = countResultsWithFindings(results);
|
|
1347
1616
|
const totalSkills = results.length;
|
|
1348
1617
|
// Audit is advisory only — always exit 0 for validation results.
|
|
1349
1618
|
// Use vat skills validate for gated validation (exit 1 on errors).
|
|
@@ -1355,6 +1624,11 @@ function logHierarchicalSummary(results, hierarchical, logger) {
|
|
|
1355
1624
|
const warningCount = results.filter((r) => r.status === 'warning').length;
|
|
1356
1625
|
logger.info(`Audit passed with warnings: ${warningCount} skill(s) (${totalSkills} scanned, ${skillsWithIssues} with issues)`);
|
|
1357
1626
|
}
|
|
1627
|
+
else if (skillsWithIssues > 0) {
|
|
1628
|
+
// `success` means "nothing actionable", NOT "nothing to see". Saying only
|
|
1629
|
+
// "N passed" over a pile of info findings is how they stayed invisible.
|
|
1630
|
+
logger.info(`Audit successful: ${totalSkills} skill(s) passed (${skillsWithIssues} with informational findings)`);
|
|
1631
|
+
}
|
|
1358
1632
|
else {
|
|
1359
1633
|
logger.info(`Audit successful: ${totalSkills} skill(s) passed`);
|
|
1360
1634
|
}
|