@vibe-agent-toolkit/cli 0.1.41-rc.5 → 0.1.41-rc.7
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 +1 -1
- package/dist/bin/vat.js +1 -1
- package/dist/bin/vat.js.map +1 -1
- package/dist/bin.js +2 -4
- package/dist/bin.js.map +1 -1
- package/dist/commands/agent/help.d.ts.map +1 -1
- package/dist/commands/agent/help.js +2 -4
- package/dist/commands/agent/help.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 +63 -1
- package/dist/commands/audit.d.ts.map +1 -1
- package/dist/commands/audit.js +329 -187
- package/dist/commands/audit.js.map +1 -1
- package/dist/commands/build.d.ts.map +1 -1
- package/dist/commands/build.js +47 -18
- 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.map +1 -1
- package/dist/commands/claude/marketplace/validate.js +13 -19
- package/dist/commands/claude/marketplace/validate.js.map +1 -1
- package/dist/commands/claude/org/skills.d.ts +21 -0
- package/dist/commands/claude/org/skills.d.ts.map +1 -1
- package/dist/commands/claude/org/skills.js +107 -30
- package/dist/commands/claude/org/skills.js.map +1 -1
- package/dist/commands/claude/plugin/build.d.ts +94 -1
- package/dist/commands/claude/plugin/build.d.ts.map +1 -1
- package/dist/commands/claude/plugin/build.js +307 -106
- package/dist/commands/claude/plugin/build.js.map +1 -1
- package/dist/commands/claude/plugin/install.d.ts.map +1 -1
- package/dist/commands/claude/plugin/install.js +7 -2
- package/dist/commands/claude/plugin/install.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 +92 -14
- package/dist/commands/claude/plugin/plugin-json-merge.js.map +1 -1
- package/dist/commands/claude/plugin/tree-copy.d.ts +10 -3
- 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/claude/plugin/uninstall.d.ts.map +1 -1
- package/dist/commands/claude/plugin/uninstall.js +10 -2
- package/dist/commands/claude/plugin/uninstall.js.map +1 -1
- package/dist/commands/corpus/runner.d.ts.map +1 -1
- package/dist/commands/corpus/runner.js +15 -26
- 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/phase-utils.d.ts +62 -2
- package/dist/commands/phase-utils.d.ts.map +1 -1
- package/dist/commands/phase-utils.js +74 -2
- package/dist/commands/phase-utils.js.map +1 -1
- package/dist/commands/rag/help.d.ts.map +1 -1
- package/dist/commands/rag/help.js +2 -4
- package/dist/commands/rag/help.js.map +1 -1
- package/dist/commands/resources/help.d.ts.map +1 -1
- package/dist/commands/resources/help.js +2 -4
- package/dist/commands/resources/help.js.map +1 -1
- package/dist/commands/resources/index.d.ts.map +1 -1
- package/dist/commands/resources/index.js +13 -6
- package/dist/commands/resources/index.js.map +1 -1
- package/dist/commands/resources/validate.d.ts +19 -0
- package/dist/commands/resources/validate.d.ts.map +1 -1
- package/dist/commands/resources/validate.js +89 -24
- 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 +33 -20
- package/dist/commands/skill/review.js.map +1 -1
- package/dist/commands/skill/test/configure.d.ts.map +1 -1
- package/dist/commands/skill/test/configure.js +1 -1
- package/dist/commands/skill/test/configure.js.map +1 -1
- package/dist/commands/skill/test/run.d.ts +29 -2
- package/dist/commands/skill/test/run.d.ts.map +1 -1
- package/dist/commands/skill/test/run.js +67 -19
- package/dist/commands/skill/test/run.js.map +1 -1
- package/dist/commands/skills/build.d.ts +51 -0
- package/dist/commands/skills/build.d.ts.map +1 -1
- package/dist/commands/skills/build.js +94 -86
- package/dist/commands/skills/build.js.map +1 -1
- package/dist/commands/skills/install.d.ts.map +1 -1
- package/dist/commands/skills/install.js +79 -25
- package/dist/commands/skills/install.js.map +1 -1
- package/dist/commands/skills/list.d.ts.map +1 -1
- package/dist/commands/skills/list.js +9 -5
- package/dist/commands/skills/list.js.map +1 -1
- package/dist/commands/skills/package.d.ts +2 -0
- package/dist/commands/skills/package.d.ts.map +1 -1
- package/dist/commands/skills/package.js +61 -42
- package/dist/commands/skills/package.js.map +1 -1
- package/dist/commands/skills/skill-discovery.d.ts +7 -0
- package/dist/commands/skills/skill-discovery.d.ts.map +1 -1
- package/dist/commands/skills/skill-discovery.js +14 -4
- package/dist/commands/skills/skill-discovery.js.map +1 -1
- package/dist/commands/skills/validate.d.ts +38 -0
- package/dist/commands/skills/validate.d.ts.map +1 -1
- package/dist/commands/skills/validate.js +125 -70
- package/dist/commands/skills/validate.js.map +1 -1
- package/dist/commands/validate.d.ts.map +1 -1
- package/dist/commands/validate.js +11 -5
- package/dist/commands/validate.js.map +1 -1
- package/dist/commands/verify.d.ts.map +1 -1
- package/dist/commands/verify.js +76 -28
- package/dist/commands/verify.js.map +1 -1
- package/dist/skill-resolution/classify.d.ts +7 -2
- package/dist/skill-resolution/classify.d.ts.map +1 -1
- package/dist/skill-resolution/classify.js +7 -2
- package/dist/skill-resolution/classify.js.map +1 -1
- package/dist/skill-resolution/resolve-skill-reference.d.ts +10 -8
- package/dist/skill-resolution/resolve-skill-reference.d.ts.map +1 -1
- package/dist/skill-resolution/resolve-skill-reference.js +62 -23
- package/dist/skill-resolution/resolve-skill-reference.js.map +1 -1
- package/dist/skill-resolution/types.d.ts +3 -2
- package/dist/skill-resolution/types.d.ts.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 +16 -0
- package/dist/utils/help-loader.d.ts.map +1 -1
- package/dist/utils/help-loader.js +31 -1
- 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 +61 -0
- package/dist/utils/issue-rendering.d.ts.map +1 -0
- package/dist/utils/issue-rendering.js +135 -0
- package/dist/utils/issue-rendering.js.map +1 -0
- package/dist/utils/output.d.ts +26 -4
- package/dist/utils/output.d.ts.map +1 -1
- package/dist/utils/output.js +27 -6
- 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/skill-packaging-config.d.ts +15 -6
- package/dist/utils/skill-packaging-config.d.ts.map +1 -1
- package/dist/utils/skill-packaging-config.js +23 -5
- 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 +10 -2
- package/dist/utils/verdict-helpers.d.ts.map +1 -1
- package/dist/utils/verdict-helpers.js +13 -3
- 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/resources.md +24 -8
- package/docs/skill-test.md +47 -3
- package/docs/skills.md +28 -6
- package/package.json +12 -12
package/dist/commands/audit.js
CHANGED
|
@@ -4,15 +4,17 @@
|
|
|
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
14
|
import { resetSkillDiscoveryCache, 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 { formatIssueAnchor } from '../utils/issue-anchor.js';
|
|
16
18
|
import { createLogger } from '../utils/logger.js';
|
|
17
19
|
import { writeYamlOutput } from '../utils/output.js';
|
|
18
20
|
import { mergeSkillPackagingConfig } from '../utils/skill-packaging-config.js';
|
|
@@ -87,33 +89,53 @@ async function buildVATProjectContext(scanRoot, logger) {
|
|
|
87
89
|
* Compat verdicts (COMPAT_TARGET_*) are computed from the result's observations
|
|
88
90
|
* and the config-level targets, then merged into the issue list.
|
|
89
91
|
*/
|
|
90
|
-
|
|
92
|
+
/**
|
|
93
|
+
* The one-line human summary of a severity distribution.
|
|
94
|
+
*
|
|
95
|
+
* Every result that carries findings renders its counts the same way, so a reader
|
|
96
|
+
* comparing two entries is comparing the same sentence.
|
|
97
|
+
*/
|
|
98
|
+
function formatCountsSummary(counts) {
|
|
99
|
+
return `${counts.errors} errors, ${counts.warnings} warnings, ${counts.info} info`;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Append findings to a result and re-derive everything that describes them.
|
|
103
|
+
*
|
|
104
|
+
* The audit pipeline appends detector findings AFTER the primary validator has
|
|
105
|
+
* already published a status, a counts block and a summary line. Pushing into
|
|
106
|
+
* `issues` alone left all three describing the pre-append issue list — a plugin
|
|
107
|
+
* with a warning reported `status: success`, `issueCounts: {0,0,0}` and
|
|
108
|
+
* `summary: Valid plugin` in the very document that listed the warning.
|
|
109
|
+
*
|
|
110
|
+
* Mutates in place, because the audit pipeline hands these results around by
|
|
111
|
+
* reference and there is no single place a rebuilt copy could be swapped in.
|
|
112
|
+
*/
|
|
113
|
+
function appendIssues(result, issues) {
|
|
114
|
+
if (issues.length === 0) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
result.issues.push(...issues);
|
|
118
|
+
const counts = countBySeverity(result.issues);
|
|
119
|
+
result.status = calculateValidationStatus(result.issues);
|
|
120
|
+
result.issueCounts = counts;
|
|
121
|
+
result.summary = formatCountsSummary(counts);
|
|
122
|
+
}
|
|
123
|
+
function packagingResultToValidationResult(skillPath, result, configTargets, locationRoot) {
|
|
91
124
|
// allErrors contains all emitted issues (errors + warnings) after severity resolution
|
|
92
125
|
// but without allow suppression — exactly what audit wants. Verdicts are
|
|
93
126
|
// computed from the observations carried on the result.
|
|
94
|
-
const verdictIssues = computeConfigVerdicts(result.observations, configTargets, skillPath);
|
|
127
|
+
const verdictIssues = computeConfigVerdicts(result.observations, configTargets, skillPath, locationRoot);
|
|
95
128
|
const issues = verdictIssues.length > 0
|
|
96
129
|
? [...result.allErrors, ...verdictIssues]
|
|
97
130
|
: 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
|
-
}
|
|
131
|
+
const issueCounts = countBySeverity(issues);
|
|
111
132
|
const out = {
|
|
112
133
|
path: skillPath,
|
|
113
134
|
type: RESOURCE_TYPE_AGENT_SKILL,
|
|
114
|
-
status,
|
|
115
|
-
summary:
|
|
135
|
+
status: calculateValidationStatus(issues),
|
|
136
|
+
summary: formatCountsSummary(issueCounts),
|
|
116
137
|
issues,
|
|
138
|
+
issueCounts,
|
|
117
139
|
metadata: {
|
|
118
140
|
lineCount: result.metadata.skillLines,
|
|
119
141
|
name: result.skillName,
|
|
@@ -128,7 +150,7 @@ function packagingResultToValidationResult(skillPath, result, configTargets) {
|
|
|
128
150
|
* Validate a single SKILL.md file, using config-aware validation if available.
|
|
129
151
|
* Used for direct SKILL.md and vat-agent paths passed to `vat audit`.
|
|
130
152
|
*/
|
|
131
|
-
async function validateSingleSkill(skillPath, options, logger, isVATGenerated) {
|
|
153
|
+
async function validateSingleSkill(skillPath, options, logger, locationRoot, isVATGenerated) {
|
|
132
154
|
// Try config-aware validation: walk UP to the skill's nearest-ancestor
|
|
133
155
|
// vibe-agent-toolkit.config.yaml and apply the skill's packaging block. A
|
|
134
156
|
// broken governing config is tolerated here (audit is a bulk linter): log it
|
|
@@ -146,13 +168,24 @@ async function validateSingleSkill(skillPath, options, logger, isVATGenerated) {
|
|
|
146
168
|
const skillConfig = fullConfig === null ? null : stripValidationAllowForDisplay(fullConfig);
|
|
147
169
|
if (skillConfig !== null) {
|
|
148
170
|
logger.debug(` Using config-aware validation for: ${skillPath}`);
|
|
149
|
-
const { gitTracker } = await resolveScanContext(safePath.resolve(skillPath));
|
|
150
|
-
|
|
171
|
+
const { gitTracker } = await resolveScanContext(safePath.resolve(skillPath), locationRoot);
|
|
172
|
+
// Key the shared registry on the same project root the validator derives,
|
|
173
|
+
// so every skill under one root parses the corpus once for the whole run
|
|
174
|
+
// instead of once each.
|
|
175
|
+
const skillDir = safePath.resolve(skillPath, '..');
|
|
176
|
+
const projectRoot = findProjectRoot(skillDir) ?? skillDir;
|
|
177
|
+
const sharedCtx = {
|
|
178
|
+
registry: await crawlAndResolveRegistry(projectRoot),
|
|
179
|
+
locationRoot,
|
|
180
|
+
};
|
|
181
|
+
if (gitTracker !== null) {
|
|
182
|
+
sharedCtx.gitTracker = gitTracker;
|
|
183
|
+
}
|
|
151
184
|
const packagingResult = await validateSkillForPackaging(skillPath, skillConfig, 'source', sharedCtx);
|
|
152
|
-
return packagingResultToValidationResult(skillPath, packagingResult, skillConfig.targets);
|
|
185
|
+
return packagingResultToValidationResult(skillPath, packagingResult, skillConfig.targets, locationRoot);
|
|
153
186
|
}
|
|
154
187
|
// Fallback: basic validation
|
|
155
|
-
const validateOptions = { skillPath };
|
|
188
|
+
const validateOptions = { skillPath, locationRoot };
|
|
156
189
|
if (options.warnUnreferencedFiles) {
|
|
157
190
|
validateOptions.checkUnreferencedFiles = true;
|
|
158
191
|
}
|
|
@@ -175,7 +208,7 @@ export function createAuditCommand() {
|
|
|
175
208
|
const audit = new Command('audit');
|
|
176
209
|
audit
|
|
177
210
|
.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.')
|
|
211
|
+
.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
212
|
.option('--no-recursive', 'Disable recursive directory scanning (scans top level only)')
|
|
180
213
|
.option('--exclude <glob>', 'Exclude paths matching glob pattern (repeatable)', collect, [])
|
|
181
214
|
.option('--include-artifacts', 'Include gitignored paths (build artifacts, dependencies) that are excluded by default')
|
|
@@ -208,8 +241,12 @@ Description:
|
|
|
208
241
|
|
|
209
242
|
Path or URL to audit: resource directory, registry file, SKILL.md file,
|
|
210
243
|
scan directory, or a git URL (HTTPS, SSH, GitHub shorthand, GitHub web URL).
|
|
211
|
-
|
|
212
|
-
|
|
244
|
+
An argument naming an existing file or directory is always a path — bare
|
|
245
|
+
owner/repo shorthand only applies when nothing of that name exists
|
|
246
|
+
locally, so "vat audit plugins/arc" audits your directory and never
|
|
247
|
+
contacts the network. When given a URL, VAT shallow-clones to a temp
|
|
248
|
+
directory, audits, and cleans up on exit. See packages/cli/docs/audit.md
|
|
249
|
+
for URL forms.
|
|
213
250
|
Default: current directory
|
|
214
251
|
Use --user to audit user-level installation automatically
|
|
215
252
|
|
|
@@ -298,7 +335,10 @@ Examples:
|
|
|
298
335
|
* and outputs hierarchical YAML. Calls process.exit() when done.
|
|
299
336
|
*/
|
|
300
337
|
async function auditUserDirectories(recursive, options, startTime, logger) {
|
|
301
|
-
const { pluginsDir, skillsDir, marketplacesDir } = getClaudeUserPaths();
|
|
338
|
+
const { claudeDir, pluginsDir, skillsDir, marketplacesDir } = getClaudeUserPaths();
|
|
339
|
+
// --user scans three sibling directories; they share ONE enclosing Claude
|
|
340
|
+
// config dir, so that is the run's single stated root.
|
|
341
|
+
const scanRoot = safePath.resolve(claudeDir);
|
|
302
342
|
// eslint-disable-next-line security/detect-non-literal-fs-filename -- safe: path constructed from os.homedir()
|
|
303
343
|
const pluginsDirExists = fs.existsSync(pluginsDir);
|
|
304
344
|
// eslint-disable-next-line security/detect-non-literal-fs-filename -- safe: path constructed from os.homedir()
|
|
@@ -316,15 +356,15 @@ async function auditUserDirectories(recursive, options, startTime, logger) {
|
|
|
316
356
|
const results = [];
|
|
317
357
|
if (pluginsDirExists) {
|
|
318
358
|
logger.debug(`Auditing user-level plugins at: ${pluginsDir}`);
|
|
319
|
-
results.push(...await getValidationResults(pluginsDir, recursive, options, logger));
|
|
359
|
+
results.push(...await getValidationResults(pluginsDir, recursive, options, logger, scanRoot));
|
|
320
360
|
}
|
|
321
361
|
if (skillsDirExists) {
|
|
322
362
|
logger.debug(`Auditing user-level skills at: ${skillsDir}`);
|
|
323
|
-
results.push(...await getValidationResults(skillsDir, recursive, options, logger));
|
|
363
|
+
results.push(...await getValidationResults(skillsDir, recursive, options, logger, scanRoot));
|
|
324
364
|
}
|
|
325
365
|
if (marketplacesDirExists) {
|
|
326
366
|
logger.debug(`Auditing user-level marketplaces at: ${marketplacesDir}`);
|
|
327
|
-
results.push(...await getValidationResults(marketplacesDir, recursive, options, logger));
|
|
367
|
+
results.push(...await getValidationResults(marketplacesDir, recursive, options, logger, scanRoot));
|
|
328
368
|
}
|
|
329
369
|
// Run compatibility analysis if --compat flag is set
|
|
330
370
|
const compatMap = options.compat
|
|
@@ -332,13 +372,13 @@ async function auditUserDirectories(recursive, options, startTime, logger) {
|
|
|
332
372
|
: undefined;
|
|
333
373
|
const verbose = options.verbose ?? false;
|
|
334
374
|
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);
|
|
375
|
+
const hierarchical = buildHierarchicalOutput(skillResults, verbose, scanRoot);
|
|
376
|
+
const summary = calculateHierarchicalSummary(results, hierarchical, startTime, compatMap, verbose, scanRoot);
|
|
337
377
|
writeYamlOutput(summary);
|
|
338
378
|
if (verbose) {
|
|
339
|
-
renderVerboseEvidence(results, logger);
|
|
379
|
+
renderVerboseEvidence(results, scanRoot, logger);
|
|
340
380
|
}
|
|
341
|
-
logHierarchicalSummary(results,
|
|
381
|
+
logHierarchicalSummary(results, logger);
|
|
342
382
|
}
|
|
343
383
|
/**
|
|
344
384
|
* Apply severity filtering to validation results.
|
|
@@ -358,6 +398,30 @@ async function auditUserDirectories(recursive, options, startTime, logger) {
|
|
|
358
398
|
*/
|
|
359
399
|
/** Skill resource types that can have per-skill validation config. */
|
|
360
400
|
const SKILL_RESULT_TYPES = new Set([RESOURCE_TYPE_AGENT_SKILL, 'vat-agent']);
|
|
401
|
+
/**
|
|
402
|
+
* The invocation scan root: the ONE base every `path` and `location` in a
|
|
403
|
+
* report is expressed relative to.
|
|
404
|
+
*
|
|
405
|
+
* This is an ANCHOR, not a validation-policy boundary. Deliberately NOT
|
|
406
|
+
* {@link deriveConfigRoot} — a config root is discovered per resource by
|
|
407
|
+
* walking up, and a single `vat audit` spans many of them, so using it as the
|
|
408
|
+
* anchor emits one document in many coordinate systems. The scan root is
|
|
409
|
+
* whatever the operator pointed the command at, so it is well defined even
|
|
410
|
+
* where no config or git root exists at all (auditing `~/.claude`, a plugin
|
|
411
|
+
* cache, a bare directory of skills).
|
|
412
|
+
*
|
|
413
|
+
* A file target anchors at its containing directory, so its own `path` reads as
|
|
414
|
+
* the bare filename rather than as an empty string.
|
|
415
|
+
*
|
|
416
|
+
* @internal Exported so tests and the corpus runner derive the run root the same
|
|
417
|
+
* way the command does, instead of hand-rolling a second answer.
|
|
418
|
+
*/
|
|
419
|
+
export function deriveScanRoot(targetPath) {
|
|
420
|
+
const resolved = safePath.resolve(targetPath);
|
|
421
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename -- resolved is the operator's own audit target
|
|
422
|
+
const isDirectory = fs.existsSync(resolved) && fs.statSync(resolved).isDirectory();
|
|
423
|
+
return isDirectory ? resolved : safePath.resolve(resolved, '..');
|
|
424
|
+
}
|
|
361
425
|
/**
|
|
362
426
|
* Derive the project root directory for config loading.
|
|
363
427
|
*
|
|
@@ -377,23 +441,15 @@ function deriveConfigRoot(targetPath) {
|
|
|
377
441
|
* Build the recalculated status and summary after issues have been filtered.
|
|
378
442
|
*/
|
|
379
443
|
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
|
-
}
|
|
444
|
+
const counts = countBySeverity(filteredIssues);
|
|
393
445
|
return {
|
|
394
446
|
...result,
|
|
395
|
-
status,
|
|
396
|
-
|
|
447
|
+
status: calculateValidationStatus(filteredIssues),
|
|
448
|
+
// Spreading `result` carried the PRE-filter `issueCounts` forward, so a
|
|
449
|
+
// `severity: ignore` config left the counts describing findings the report
|
|
450
|
+
// no longer contained. The counts are re-derived, never inherited.
|
|
451
|
+
issueCounts: counts,
|
|
452
|
+
summary: formatCountsSummary(counts),
|
|
397
453
|
issues: filteredIssues,
|
|
398
454
|
};
|
|
399
455
|
}
|
|
@@ -459,12 +515,18 @@ async function resolveEffectiveSettings(options, scanPath, logger) {
|
|
|
459
515
|
return undefined;
|
|
460
516
|
}
|
|
461
517
|
}
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
518
|
+
/**
|
|
519
|
+
* Run the audit pipeline at `scanPath` and assemble the report document,
|
|
520
|
+
* without writing anything. The document states its coordinate system once
|
|
521
|
+
* (`root`) and every `path`/`location` beneath it is relative to that root.
|
|
522
|
+
*
|
|
523
|
+
* @internal Exported for integration testing only — not part of the public CLI API.
|
|
524
|
+
*/
|
|
525
|
+
export async function buildAuditReport(scanPath, options, startTime, logger) {
|
|
465
526
|
const recursive = options.recursive !== false;
|
|
466
527
|
logger.debug(`Auditing resources at: ${scanPath}`);
|
|
467
|
-
const
|
|
528
|
+
const scanRoot = deriveScanRoot(scanPath);
|
|
529
|
+
const rawResults = await getValidationResults(scanPath, recursive, options, logger, scanRoot);
|
|
468
530
|
// Load config for severity filtering (audit ignores allow; only severity matters).
|
|
469
531
|
const config = loadConfig(deriveConfigRoot(scanPath));
|
|
470
532
|
// Apply severity filtering: hide codes whose effective severity is 'ignore'.
|
|
@@ -480,16 +542,35 @@ async function runAuditAtPath(scanPath, options, overrides = {}) {
|
|
|
480
542
|
? await runCompatAnalysis(results, logger, effectiveSettings, vatContextForCompat)
|
|
481
543
|
: undefined;
|
|
482
544
|
const verbose = options.verbose ?? false;
|
|
483
|
-
|
|
484
|
-
|
|
545
|
+
return {
|
|
546
|
+
results,
|
|
547
|
+
document: calculateSummary(results, startTime, compatMap, verbose, scanRoot),
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
/** Strip the run root from a report whose base cannot be named as a path. */
|
|
551
|
+
function withoutRoot(document) {
|
|
552
|
+
const copy = { ...document };
|
|
553
|
+
delete copy['root'];
|
|
554
|
+
return copy;
|
|
555
|
+
}
|
|
556
|
+
async function runAuditAtPath(scanPath, options, overrides = {}) {
|
|
557
|
+
const logger = createLogger(options.debug ? { debug: true } : {});
|
|
558
|
+
const startTime = Date.now();
|
|
559
|
+
const verbose = options.verbose ?? false;
|
|
560
|
+
const { results, document } = await buildAuditReport(scanPath, options, startTime, logger);
|
|
561
|
+
// A URL audit's run root is a random tempdir: not reproducible and useless to
|
|
562
|
+
// a reader. The provenance header states the base instead (url @ ref @ commit)
|
|
563
|
+
// and every path below is already relative to the clone, so drop `root`
|
|
564
|
+
// rather than print a path nobody can resolve.
|
|
565
|
+
const finalDocument = overrides.tempRoot === undefined ? document : rewritePathsInResults(withoutRoot(document), overrides.tempRoot);
|
|
485
566
|
if (overrides.provenance) {
|
|
486
567
|
process.stdout.write(renderProvenanceHeader(overrides.provenance));
|
|
487
568
|
}
|
|
488
|
-
writeYamlOutput(
|
|
569
|
+
writeYamlOutput(finalDocument);
|
|
489
570
|
if (verbose) {
|
|
490
|
-
renderVerboseEvidence(results, logger);
|
|
571
|
+
renderVerboseEvidence(results, document.root, logger);
|
|
491
572
|
}
|
|
492
|
-
handleAuditResults(results,
|
|
573
|
+
handleAuditResults(results, document, logger);
|
|
493
574
|
logger.debug(`Audit complete in ${Date.now() - startTime}ms`);
|
|
494
575
|
}
|
|
495
576
|
async function runUrlAudit(rawInput, options) {
|
|
@@ -509,7 +590,15 @@ export async function auditCommand(targetPath, options) {
|
|
|
509
590
|
const startTime = Date.now();
|
|
510
591
|
resetAuditCaches();
|
|
511
592
|
try {
|
|
512
|
-
|
|
593
|
+
// A local path always wins. `isGitUrl` accepts bare `owner/repo` GitHub
|
|
594
|
+
// shorthand, which is indistinguishable from an ordinary two-segment
|
|
595
|
+
// relative path (`plugins/arc`, `docs/guides`, `packages/cli`). Asking
|
|
596
|
+
// the URL question first meant those never got audited — the command
|
|
597
|
+
// cloned https://github.com/<that>.git instead, reaching out to the
|
|
598
|
+
// network with a name derived from the caller's own directory layout.
|
|
599
|
+
// Shorthand is a fallback for arguments that name nothing on disk.
|
|
600
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename -- targetPath is the user's own audit target; existence is the question being asked
|
|
601
|
+
if (targetPath !== undefined && !fsExistsSync(targetPath) && isGitUrl(targetPath)) {
|
|
513
602
|
await runUrlAudit(targetPath, options);
|
|
514
603
|
return;
|
|
515
604
|
}
|
|
@@ -531,24 +620,24 @@ export async function auditCommand(targetPath, options) {
|
|
|
531
620
|
* branch of {@link getValidationResults} focused so cognitive complexity stays
|
|
532
621
|
* within the ESLint budget.
|
|
533
622
|
*/
|
|
534
|
-
async function validateSurface(surface, options, logger) {
|
|
623
|
+
async function validateSurface(surface, options, logger, locationRoot) {
|
|
535
624
|
if (surface.type === RESOURCE_TYPE_AGENT_SKILL) {
|
|
536
|
-
return validateSingleSkill(surface.path, options, logger);
|
|
625
|
+
return validateSingleSkill(surface.path, options, logger, locationRoot);
|
|
537
626
|
}
|
|
538
627
|
if (surface.type === RESOURCE_TYPE_CLAUDE_PLUGIN) {
|
|
539
|
-
return validatePlugin(surface.path);
|
|
628
|
+
return validatePlugin(surface.path, { locationRoot });
|
|
540
629
|
}
|
|
541
|
-
return validateMarketplace(surface.path);
|
|
630
|
+
return validateMarketplace(surface.path, { locationRoot });
|
|
542
631
|
}
|
|
543
632
|
/**
|
|
544
633
|
* Validate every surface enumerated at a directory root (used when
|
|
545
634
|
* `enumerateSurfaces` returns more than one — e.g., skill-claude-plugin).
|
|
546
635
|
*/
|
|
547
|
-
async function validateMultipleSurfaces(surfaces, scanPath, options, logger) {
|
|
636
|
+
async function validateMultipleSurfaces(surfaces, scanPath, options, logger, locationRoot) {
|
|
548
637
|
logger.debug(`Detected ${surfaces.length.toString()} surfaces at ${scanPath}: ${surfaces.map((s) => s.type).join(', ')}`);
|
|
549
638
|
const results = [];
|
|
550
639
|
for (const surface of surfaces) {
|
|
551
|
-
results.push(await validateSurface(surface, options, logger));
|
|
640
|
+
results.push(await validateSurface(surface, options, logger, locationRoot));
|
|
552
641
|
}
|
|
553
642
|
return results;
|
|
554
643
|
}
|
|
@@ -563,19 +652,19 @@ async function validateMultipleSurfaces(surfaces, scanPath, options, logger) {
|
|
|
563
652
|
* already extracted the inventory (e.g., to surface parse errors) and the
|
|
564
653
|
* extractor is not called again.
|
|
565
654
|
*/
|
|
566
|
-
async function runInventoryDetectors(scanPath, type, prebuiltInv) {
|
|
655
|
+
async function runInventoryDetectors(scanPath, type, locationRoot, prebuiltInv) {
|
|
567
656
|
if (type === RESOURCE_TYPE_CLAUDE_PLUGIN) {
|
|
568
|
-
const inv = prebuiltInv?.kind === 'plugin' ? prebuiltInv : await
|
|
657
|
+
const inv = prebuiltInv?.kind === 'plugin' ? prebuiltInv : await pluginInventoryAt(scanPath);
|
|
569
658
|
return [
|
|
570
|
-
...detectDeclaredButMissing(inv),
|
|
571
|
-
...detectPresentButUndeclared(inv),
|
|
572
|
-
...detectReferenceTargetMissing(inv),
|
|
573
|
-
...(inv.vendor === 'claude-code' ? detectSkillClaudePluginNameMismatch(inv) : []),
|
|
659
|
+
...detectDeclaredButMissing(inv, locationRoot),
|
|
660
|
+
...detectPresentButUndeclared(inv, locationRoot),
|
|
661
|
+
...detectReferenceTargetMissing(inv, locationRoot),
|
|
662
|
+
...(inv.vendor === 'claude-code' ? detectSkillClaudePluginNameMismatch(inv, locationRoot) : []),
|
|
574
663
|
];
|
|
575
664
|
}
|
|
576
665
|
if (type === 'marketplace') {
|
|
577
666
|
const inv = prebuiltInv?.kind === 'marketplace' ? prebuiltInv : await extractClaudeMarketplaceInventory(scanPath);
|
|
578
|
-
return detectMarketplacePluginSourceMissing(inv);
|
|
667
|
+
return detectMarketplacePluginSourceMissing(inv, locationRoot);
|
|
579
668
|
}
|
|
580
669
|
return [];
|
|
581
670
|
}
|
|
@@ -590,7 +679,7 @@ async function runInventoryDetectors(scanPath, type, prebuiltInv) {
|
|
|
590
679
|
* compared against the marketplace path itself to avoid re-auditing a
|
|
591
680
|
* co-located self-source (e.g., `source: "./"`).
|
|
592
681
|
*/
|
|
593
|
-
async function recurseIntoMarketplacePlugins(marketplaceInv, recursive, options, logger) {
|
|
682
|
+
async function recurseIntoMarketplacePlugins(marketplaceInv, recursive, options, logger, locationRoot) {
|
|
594
683
|
const marketplaceRoot = safePath.resolve(marketplaceInv.path);
|
|
595
684
|
const seen = new Set([marketplaceRoot]);
|
|
596
685
|
const results = [];
|
|
@@ -602,7 +691,8 @@ async function recurseIntoMarketplacePlugins(marketplaceInv, recursive, options,
|
|
|
602
691
|
}
|
|
603
692
|
seen.add(pluginPath);
|
|
604
693
|
logger.debug(` Recursing into marketplace path-source plugin: ${pluginPath}`);
|
|
605
|
-
|
|
694
|
+
// Same run root — recursing into a co-located plugin must NOT re-anchor.
|
|
695
|
+
const pluginResults = await getValidationResults(pluginPath, recursive, options, logger, locationRoot);
|
|
606
696
|
results.push(...pluginResults);
|
|
607
697
|
}
|
|
608
698
|
return results;
|
|
@@ -611,12 +701,12 @@ async function recurseIntoMarketplacePlugins(marketplaceInv, recursive, options,
|
|
|
611
701
|
* Append plugin inventory findings to the matching plugin result in a
|
|
612
702
|
* multi-surface result list. Mutates in place.
|
|
613
703
|
*/
|
|
614
|
-
async function appendPluginInventoryToSurfaceResults(surfaceResults, scanPath, logger) {
|
|
615
|
-
const inventoryIssues = await runInventoryDetectors(scanPath, RESOURCE_TYPE_CLAUDE_PLUGIN);
|
|
704
|
+
async function appendPluginInventoryToSurfaceResults(surfaceResults, scanPath, logger, locationRoot) {
|
|
705
|
+
const inventoryIssues = await runInventoryDetectors(scanPath, RESOURCE_TYPE_CLAUDE_PLUGIN, locationRoot);
|
|
616
706
|
logger.debug(`Inventory detectors emitted ${inventoryIssues.length.toString()} issues for plugin (multi-surface) at ${scanPath}`);
|
|
617
707
|
for (const r of surfaceResults) {
|
|
618
708
|
if (r.type === RESOURCE_TYPE_CLAUDE_PLUGIN) {
|
|
619
|
-
r
|
|
709
|
+
appendIssues(r, inventoryIssues);
|
|
620
710
|
break;
|
|
621
711
|
}
|
|
622
712
|
}
|
|
@@ -627,35 +717,45 @@ async function appendPluginInventoryToSurfaceResults(surfaceResults, scanPath, l
|
|
|
627
717
|
* by `validatePlugin`). Only hooks/hooks.json and .mcp.json parse errors reach
|
|
628
718
|
* this helper; they are appended with error severity.
|
|
629
719
|
*/
|
|
630
|
-
function appendInventoryParseErrors(result, inv) {
|
|
720
|
+
function appendInventoryParseErrors(result, inv, locationRoot) {
|
|
631
721
|
const pluginJsonSuffix = safePath.join('.claude-plugin', 'plugin.json');
|
|
722
|
+
const parseIssues = [];
|
|
632
723
|
for (const err of inv.parseErrors) {
|
|
633
724
|
if (err.path.endsWith(pluginJsonSuffix))
|
|
634
725
|
continue;
|
|
635
|
-
|
|
726
|
+
parseIssues.push({
|
|
636
727
|
severity: 'error',
|
|
637
728
|
code: 'PLUGIN_INVALID_JSON',
|
|
638
729
|
message: err.message,
|
|
639
|
-
location: err.path,
|
|
730
|
+
location: issueLocation(err.path, locationRoot),
|
|
640
731
|
});
|
|
641
|
-
result.status = 'error';
|
|
642
732
|
}
|
|
733
|
+
// Status was hand-set to 'error' here while `issueCounts` and `summary` kept
|
|
734
|
+
// describing the pre-append list. One appender derives all three.
|
|
735
|
+
appendIssues(result, parseIssues);
|
|
643
736
|
}
|
|
644
737
|
/**
|
|
645
738
|
* @internal Exported for integration testing only — not part of the public CLI API.
|
|
739
|
+
*
|
|
740
|
+
* @param locationRoot - The invocation scan root: the ONE base every emitted
|
|
741
|
+
* `ValidationIssue.location` in this run is expressed relative to. It is an
|
|
742
|
+
* ANCHOR, not a validation-policy boundary — per-skill governing-config
|
|
743
|
+
* discovery still decides packaging rules, but it must never decide how a
|
|
744
|
+
* location reads, or a run spanning several configs emits one document in
|
|
745
|
+
* several coordinate systems. Recursion passes it through unchanged.
|
|
646
746
|
*/
|
|
647
|
-
export async function getValidationResults(scanPath, recursive, options, logger) {
|
|
747
|
+
export async function getValidationResults(scanPath, recursive, options, logger, locationRoot) {
|
|
648
748
|
const format = detectFormat(scanPath);
|
|
649
749
|
// Special handling for direct SKILL.md file
|
|
650
750
|
if (format === RESOURCE_TYPE_AGENT_SKILL) {
|
|
651
751
|
logger.debug('Detected single Agent Skill');
|
|
652
|
-
return [await validateSingleSkill(scanPath, options, logger)];
|
|
752
|
+
return [await validateSingleSkill(scanPath, options, logger, locationRoot)];
|
|
653
753
|
}
|
|
654
754
|
// Special handling for VAT agent: validate its SKILL.md
|
|
655
755
|
if (format === 'vat-agent') {
|
|
656
756
|
const skillPath = safePath.join(scanPath, 'SKILL.md');
|
|
657
757
|
logger.debug('Detected VAT agent, validating SKILL.md');
|
|
658
|
-
return [await validateSingleSkill(skillPath, options, logger, true)];
|
|
758
|
+
return [await validateSingleSkill(skillPath, options, logger, locationRoot, true)];
|
|
659
759
|
}
|
|
660
760
|
// Enumerate all manifest surfaces at the directory root. If multiple are
|
|
661
761
|
// present (e.g., skill-claude-plugin: SKILL.md + .claude-plugin/plugin.json),
|
|
@@ -664,7 +764,7 @@ export async function getValidationResults(scanPath, recursive, options, logger)
|
|
|
664
764
|
// swallowed by the plugin surface.
|
|
665
765
|
const surfaces = await enumerateSurfaces(scanPath);
|
|
666
766
|
if (surfaces.length > 1) {
|
|
667
|
-
const surfaceResults = await validateMultipleSurfaces(surfaces, scanPath, options, logger);
|
|
767
|
+
const surfaceResults = await validateMultipleSurfaces(surfaces, scanPath, options, logger, locationRoot);
|
|
668
768
|
if (surfaces.some((s) => s.type === RESOURCE_TYPE_CLAUDE_PLUGIN)) {
|
|
669
769
|
// Build exclusion set from skill paths already validated as surfaces, so that
|
|
670
770
|
// validatePluginSkillsViaInventory does not double-count a root SKILL.md that
|
|
@@ -672,8 +772,8 @@ export async function getValidationResults(scanPath, recursive, options, logger)
|
|
|
672
772
|
const alreadyValidated = new Set(surfaces
|
|
673
773
|
.filter((s) => s.type === RESOURCE_TYPE_AGENT_SKILL)
|
|
674
774
|
.map((s) => safePath.resolve(s.path)));
|
|
675
|
-
surfaceResults.push(...(await validatePluginSkillsViaInventory(scanPath, options, logger, alreadyValidated)));
|
|
676
|
-
await appendPluginInventoryToSurfaceResults(surfaceResults, scanPath, logger);
|
|
775
|
+
surfaceResults.push(...(await validatePluginSkillsViaInventory(scanPath, options, logger, locationRoot, alreadyValidated)));
|
|
776
|
+
await appendPluginInventoryToSurfaceResults(surfaceResults, scanPath, logger, locationRoot);
|
|
677
777
|
}
|
|
678
778
|
return surfaceResults;
|
|
679
779
|
}
|
|
@@ -681,16 +781,16 @@ export async function getValidationResults(scanPath, recursive, options, logger)
|
|
|
681
781
|
const resourceFormat = await detectResourceFormat(scanPath);
|
|
682
782
|
if (resourceFormat.type !== 'unknown') {
|
|
683
783
|
logger.debug(`Detected ${resourceFormat.type} at: ${scanPath}`);
|
|
684
|
-
const result = await validate(scanPath, { validatePlugin });
|
|
784
|
+
const result = await validate(scanPath, { validatePlugin, locationRoot });
|
|
685
785
|
if (resourceFormat.type === RESOURCE_TYPE_CLAUDE_PLUGIN) {
|
|
686
786
|
// 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
|
|
787
|
+
const inv = await pluginInventoryAt(scanPath);
|
|
788
|
+
appendInventoryParseErrors(result, inv, locationRoot);
|
|
789
|
+
const pluginInventoryIssues = await runInventoryDetectors(scanPath, RESOURCE_TYPE_CLAUDE_PLUGIN, locationRoot, inv);
|
|
790
|
+
appendIssues(result, pluginInventoryIssues);
|
|
691
791
|
logger.debug(`Inventory detectors emitted ${pluginInventoryIssues.length.toString()} issues for plugin at ${scanPath}`);
|
|
692
792
|
// Also validate every skill the plugin ships via the inventory walker.
|
|
693
|
-
const skillResults = await validatePluginSkillsViaInventory(scanPath, options, logger);
|
|
793
|
+
const skillResults = await validatePluginSkillsViaInventory(scanPath, options, logger, locationRoot);
|
|
694
794
|
return [result, ...skillResults];
|
|
695
795
|
}
|
|
696
796
|
if (resourceFormat.type === 'marketplace') {
|
|
@@ -699,10 +799,10 @@ export async function getValidationResults(scanPath, recursive, options, logger)
|
|
|
699
799
|
// is populated only for path sources that exist on disk (git/npm/unknown
|
|
700
800
|
// sources are declarations only and stay out of scope).
|
|
701
801
|
const marketplaceInv = await extractClaudeMarketplaceInventory(scanPath);
|
|
702
|
-
const marketplaceInventoryIssues = await runInventoryDetectors(scanPath, 'marketplace', marketplaceInv);
|
|
703
|
-
result
|
|
802
|
+
const marketplaceInventoryIssues = await runInventoryDetectors(scanPath, 'marketplace', locationRoot, marketplaceInv);
|
|
803
|
+
appendIssues(result, marketplaceInventoryIssues);
|
|
704
804
|
logger.debug(`Inventory detectors emitted ${marketplaceInventoryIssues.length.toString()} issues for marketplace at ${scanPath}`);
|
|
705
|
-
const pluginResults = await recurseIntoMarketplacePlugins(marketplaceInv, recursive, options, logger);
|
|
805
|
+
const pluginResults = await recurseIntoMarketplacePlugins(marketplaceInv, recursive, options, logger, locationRoot);
|
|
706
806
|
return [result, ...pluginResults];
|
|
707
807
|
}
|
|
708
808
|
return [result];
|
|
@@ -720,7 +820,7 @@ export async function getValidationResults(scanPath, recursive, options, logger)
|
|
|
720
820
|
const mergedOptions = configExcludes.length > 0
|
|
721
821
|
? { ...options, exclude: [...(options.exclude ?? []), ...configExcludes] }
|
|
722
822
|
: options;
|
|
723
|
-
return scanDirectory(scanPath, recursive, mergedOptions, logger);
|
|
823
|
+
return scanDirectory(scanPath, recursive, mergedOptions, logger, await resolveScanContext(scanPath, locationRoot));
|
|
724
824
|
}
|
|
725
825
|
}
|
|
726
826
|
catch {
|
|
@@ -728,7 +828,7 @@ export async function getValidationResults(scanPath, recursive, options, logger)
|
|
|
728
828
|
}
|
|
729
829
|
// Unknown resource type - use unified validator which will return appropriate error
|
|
730
830
|
logger.debug(`Unknown resource type at: ${scanPath}`);
|
|
731
|
-
const result = await validate(scanPath);
|
|
831
|
+
const result = await validate(scanPath, { locationRoot });
|
|
732
832
|
return [result];
|
|
733
833
|
}
|
|
734
834
|
/**
|
|
@@ -740,8 +840,8 @@ export async function getValidationResults(scanPath, recursive, options, logger)
|
|
|
740
840
|
* multi-surface branch to avoid double-counting a root SKILL.md that
|
|
741
841
|
* `validateMultipleSurfaces` already validated as an `agent-skill` surface.
|
|
742
842
|
*/
|
|
743
|
-
async function validatePluginSkillsViaInventory(scanPath, options, logger, excludeSkillPaths = new Set()) {
|
|
744
|
-
const inv = await
|
|
843
|
+
async function validatePluginSkillsViaInventory(scanPath, options, logger, locationRoot, excludeSkillPaths = new Set()) {
|
|
844
|
+
const inv = await pluginInventoryAt(scanPath);
|
|
745
845
|
const results = [];
|
|
746
846
|
for (const skill of inv.discovered.skills) {
|
|
747
847
|
const resolvedPath = safePath.resolve(skill.files.skillMd);
|
|
@@ -750,7 +850,7 @@ async function validatePluginSkillsViaInventory(scanPath, options, logger, exclu
|
|
|
750
850
|
continue;
|
|
751
851
|
}
|
|
752
852
|
logger.debug(` Validating plugin-bundled skill (inventory): ${skill.files.skillMd}`);
|
|
753
|
-
results.push(await validateSingleSkill(skill.files.skillMd, options, logger));
|
|
853
|
+
results.push(await validateSingleSkill(skill.files.skillMd, options, logger, locationRoot));
|
|
754
854
|
}
|
|
755
855
|
return results;
|
|
756
856
|
}
|
|
@@ -893,12 +993,35 @@ function applyVerboseFilter(results, verbose) {
|
|
|
893
993
|
return stripped;
|
|
894
994
|
});
|
|
895
995
|
}
|
|
896
|
-
|
|
996
|
+
/**
|
|
997
|
+
* Re-base each emitted file entry's `path` onto the run root.
|
|
998
|
+
*
|
|
999
|
+
* `ValidationResult.path` stays ABSOLUTE inside the pipeline on purpose: it is
|
|
1000
|
+
* the identity every internal map keys on (compat results, visited-path
|
|
1001
|
+
* de-duplication, registry caches), and unlike `location` it is trivially
|
|
1002
|
+
* recoverable — an absolute path plus the known root yields the relative form.
|
|
1003
|
+
* `location` is the opposite: it cannot be re-based here because a relative
|
|
1004
|
+
* string whose base was never stated is not recoverable, which is exactly why
|
|
1005
|
+
* the base is threaded down to the producers instead.
|
|
1006
|
+
*/
|
|
1007
|
+
function relativizeFileEntries(entries, root) {
|
|
1008
|
+
return entries.map((entry) => {
|
|
1009
|
+
const relative = issueLocation(entry.path, root);
|
|
1010
|
+
// Pointing audit AT a single resource makes that resource the root, which
|
|
1011
|
+
// relativizes to the empty string. Spell it `.` — the POSIX name for "the
|
|
1012
|
+
// root itself" — so no consumer has to special-case an empty path, and
|
|
1013
|
+
// `join(root, path)` still resolves.
|
|
1014
|
+
return { ...entry, path: relative === '' ? '.' : relative };
|
|
1015
|
+
});
|
|
1016
|
+
}
|
|
1017
|
+
function calculateSummary(results, startTime, compatMap, verbose, root) {
|
|
897
1018
|
const base = buildBaseSummary(results, startTime);
|
|
898
1019
|
const withCompat = applyCompatMap(results, compatMap);
|
|
899
1020
|
return {
|
|
1021
|
+
// Stated once, first, and the only absolute path in the document.
|
|
1022
|
+
root,
|
|
900
1023
|
...base,
|
|
901
|
-
files: applyVerboseFilter(withCompat, verbose),
|
|
1024
|
+
files: relativizeFileEntries(applyVerboseFilter(withCompat, verbose), root),
|
|
902
1025
|
};
|
|
903
1026
|
}
|
|
904
1027
|
/**
|
|
@@ -948,7 +1071,7 @@ function renderIssueEvidence(issue, evidenceSources, logger) {
|
|
|
948
1071
|
* result itself (per-file SKILL evidence) and from any attached
|
|
949
1072
|
* `compatibility.evidence` (plugin-level scanner output).
|
|
950
1073
|
*/
|
|
951
|
-
function renderVerboseEvidence(results, logger) {
|
|
1074
|
+
function renderVerboseEvidence(results, root, logger) {
|
|
952
1075
|
for (const result of results) {
|
|
953
1076
|
const capabilityIssues = (result.issues ?? []).filter((i) => i.code.startsWith('CAPABILITY_'));
|
|
954
1077
|
if (capabilityIssues.length === 0)
|
|
@@ -959,14 +1082,14 @@ function renderVerboseEvidence(results, logger) {
|
|
|
959
1082
|
];
|
|
960
1083
|
if (evidenceSources.length === 0)
|
|
961
1084
|
continue;
|
|
962
|
-
logger.info(`\n${result.path} — supporting evidence:`);
|
|
1085
|
+
logger.info(`\n${issueLocation(result.path, root)} — supporting evidence:`);
|
|
963
1086
|
for (const issue of capabilityIssues) {
|
|
964
1087
|
renderIssueEvidence(issue, evidenceSources, logger);
|
|
965
1088
|
}
|
|
966
1089
|
}
|
|
967
1090
|
}
|
|
968
1091
|
function handleAuditResults(results, summary, logger) {
|
|
969
|
-
const {
|
|
1092
|
+
const { filesWithErrors: errorCount, filesWithWarnings: warningCount, filesPassed: successCount, } = summary.summary;
|
|
970
1093
|
// Report settings conflicts (advisory, non-blocking)
|
|
971
1094
|
const totalSettingsConflicts = (summary.files ?? []).reduce((sum, f) => {
|
|
972
1095
|
return sum + (f.compatibility?.settingsConflicts?.length ?? 0);
|
|
@@ -977,13 +1100,18 @@ function handleAuditResults(results, summary, logger) {
|
|
|
977
1100
|
// Audit is advisory only — always exit 0 for validation results.
|
|
978
1101
|
// Use vat skills validate for gated validation (exit 1 on errors).
|
|
979
1102
|
if (errorCount > 0) {
|
|
980
|
-
logErrors(results, errorCount, logger);
|
|
1103
|
+
logErrors(results, errorCount, summary.root, logger);
|
|
981
1104
|
}
|
|
982
1105
|
else if (warningCount > 0) {
|
|
983
|
-
logWarnings(results, warningCount, logger);
|
|
1106
|
+
logWarnings(results, warningCount, summary.root, logger);
|
|
984
1107
|
}
|
|
985
1108
|
else {
|
|
986
|
-
|
|
1109
|
+
// `success` means "nothing actionable", NOT "nothing to see" — name the
|
|
1110
|
+
// informational findings or the YAML and the stderr line disagree.
|
|
1111
|
+
const withFindings = countResultsWithFindings(results);
|
|
1112
|
+
logger.info(withFindings > 0
|
|
1113
|
+
? `Audit successful: ${successCount} file(s) passed (${withFindings} with informational findings)`
|
|
1114
|
+
: `Audit successful: ${successCount} file(s) passed`);
|
|
987
1115
|
}
|
|
988
1116
|
renderAuditFooter(results, logger);
|
|
989
1117
|
process.exit(0);
|
|
@@ -1007,20 +1135,20 @@ function renderAuditFooter(results, logger) {
|
|
|
1007
1135
|
}
|
|
1008
1136
|
renderSkillQualityFooter(logger, hasSkillFindings, emittedCodes);
|
|
1009
1137
|
}
|
|
1010
|
-
function logErrors(results, errorCount, logger) {
|
|
1138
|
+
function logErrors(results, errorCount, root, logger) {
|
|
1011
1139
|
logger.error(`Audit failed: ${errorCount} file(s) with errors`);
|
|
1012
1140
|
const errorResults = results.filter((r) => r.status === 'error');
|
|
1013
1141
|
for (const result of errorResults) {
|
|
1014
|
-
logger.error(`\n${result.path}:`);
|
|
1142
|
+
logger.error(`\n${issueLocation(result.path, root)}:`);
|
|
1015
1143
|
const errorIssues = result.issues.filter((i) => i.severity === 'error');
|
|
1016
1144
|
logIssues(errorIssues, logger.error.bind(logger));
|
|
1017
1145
|
}
|
|
1018
1146
|
}
|
|
1019
|
-
function logWarnings(results, warningCount, logger) {
|
|
1147
|
+
function logWarnings(results, warningCount, root, logger) {
|
|
1020
1148
|
logger.info(`Audit passed with warnings: ${warningCount} file(s)`);
|
|
1021
1149
|
const warningResults = results.filter((r) => r.status === 'warning');
|
|
1022
1150
|
for (const result of warningResults) {
|
|
1023
|
-
logger.info(`\n${result.path}:`);
|
|
1151
|
+
logger.info(`\n${issueLocation(result.path, root)}:`);
|
|
1024
1152
|
const warningIssues = result.issues.filter((i) => i.severity === 'warning');
|
|
1025
1153
|
logIssues(warningIssues, logger.info.bind(logger));
|
|
1026
1154
|
}
|
|
@@ -1028,8 +1156,9 @@ function logWarnings(results, warningCount, logger) {
|
|
|
1028
1156
|
function logIssues(issues, logFn) {
|
|
1029
1157
|
for (const issue of issues) {
|
|
1030
1158
|
logFn(` [${issue.code}] ${issue.message}`);
|
|
1031
|
-
|
|
1032
|
-
|
|
1159
|
+
const anchor = formatIssueAnchor(issue);
|
|
1160
|
+
if (anchor !== undefined) {
|
|
1161
|
+
logFn(` at: ${anchor}`);
|
|
1033
1162
|
}
|
|
1034
1163
|
if (issue.fix) {
|
|
1035
1164
|
logFn(` fix: ${issue.fix}`);
|
|
@@ -1051,10 +1180,11 @@ function isExcludedPath(isMatch, relativePath, isDirectory) {
|
|
|
1051
1180
|
* Handle file entry during directory scan
|
|
1052
1181
|
*/
|
|
1053
1182
|
async function handleFileEntry(entry, fullPath, options, logger, scanCtx) {
|
|
1183
|
+
const { locationRoot } = scanCtx;
|
|
1054
1184
|
// Check for registry files
|
|
1055
1185
|
if (entry.name === 'installed_plugins.json' || entry.name === 'known_marketplaces.json') {
|
|
1056
1186
|
logger.debug(`Validating registry: ${fullPath}`);
|
|
1057
|
-
return validate(fullPath);
|
|
1187
|
+
return validate(fullPath, { locationRoot });
|
|
1058
1188
|
}
|
|
1059
1189
|
// Check for SKILL.md
|
|
1060
1190
|
if (entry.name === 'SKILL.md') {
|
|
@@ -1068,12 +1198,14 @@ async function handleFileEntry(entry, fullPath, options, logger, scanCtx) {
|
|
|
1068
1198
|
logger.debug(` Using config-aware validation for: ${fullPath}`);
|
|
1069
1199
|
// Thread the per-scan tracker into packaging validation so gitignore
|
|
1070
1200
|
// checks in the link-graph walk stay O(1).
|
|
1071
|
-
const sharedCtx = scanCtx.gitTracker === null
|
|
1201
|
+
const sharedCtx = scanCtx.gitTracker === null
|
|
1202
|
+
? { locationRoot }
|
|
1203
|
+
: { gitTracker: scanCtx.gitTracker, locationRoot };
|
|
1072
1204
|
const packagingResult = await validateSkillForPackaging(fullPath, skillConfig, 'source', sharedCtx);
|
|
1073
|
-
return packagingResultToValidationResult(fullPath, packagingResult, skillConfig.targets);
|
|
1205
|
+
return packagingResultToValidationResult(fullPath, packagingResult, skillConfig.targets, locationRoot);
|
|
1074
1206
|
}
|
|
1075
1207
|
// Wild mode: basic validation
|
|
1076
|
-
const validateOptions = { skillPath: fullPath };
|
|
1208
|
+
const validateOptions = { skillPath: fullPath, locationRoot };
|
|
1077
1209
|
if (options.warnUnreferencedFiles) {
|
|
1078
1210
|
validateOptions.checkUnreferencedFiles = true;
|
|
1079
1211
|
}
|
|
@@ -1086,26 +1218,57 @@ async function handleFileEntry(entry, fullPath, options, logger, scanCtx) {
|
|
|
1086
1218
|
*/
|
|
1087
1219
|
async function handleDirectoryEntry(fullPath, recursive, options, logger, baseDir, scanCtx, nestedConfigLog) {
|
|
1088
1220
|
const fs = await import('node:fs/promises');
|
|
1221
|
+
const { locationRoot } = scanCtx;
|
|
1089
1222
|
const results = [];
|
|
1090
1223
|
// Check if directory contains a plugin or marketplace
|
|
1091
1224
|
const claudePluginDir = safePath.join(fullPath, '.claude-plugin');
|
|
1092
1225
|
const hasClaudePlugin = await fs.access(claudePluginDir).then(() => true).catch(() => false);
|
|
1093
1226
|
if (hasClaudePlugin) {
|
|
1094
1227
|
logger.debug(`Validating resource directory: ${fullPath}`);
|
|
1095
|
-
const result = await validate(fullPath, { validatePlugin });
|
|
1096
|
-
const inv = await
|
|
1097
|
-
appendInventoryParseErrors(result, inv);
|
|
1228
|
+
const result = await validate(fullPath, { validatePlugin, locationRoot });
|
|
1229
|
+
const inv = await pluginInventoryAt(fullPath);
|
|
1230
|
+
appendInventoryParseErrors(result, inv, locationRoot);
|
|
1098
1231
|
results.push(result);
|
|
1099
1232
|
}
|
|
1100
1233
|
// Recurse into subdirectories (both plugin/marketplace dirs and regular dirs)
|
|
1101
1234
|
if (recursive) {
|
|
1102
|
-
const subResults = await scanDirectory(fullPath, recursive, options, logger,
|
|
1235
|
+
const subResults = await scanDirectory(fullPath, recursive, options, logger, scanCtx, baseDir, nestedConfigLog);
|
|
1103
1236
|
results.push(...subResults);
|
|
1104
1237
|
}
|
|
1105
1238
|
return results;
|
|
1106
1239
|
}
|
|
1107
1240
|
/** Cache of (gitRoot → initialized GitTracker) to avoid re-spawning ls-files. */
|
|
1108
1241
|
const gitTrackerCache = new Map();
|
|
1242
|
+
/**
|
|
1243
|
+
* Cache of (projectRoot → registry for the *inventory* link walk).
|
|
1244
|
+
*
|
|
1245
|
+
* Deliberately separate from the validator's own registry memo (which now lives
|
|
1246
|
+
* beside `crawlAndResolveRegistry`, so this lane no longer keeps a second copy of
|
|
1247
|
+
* that cache): the validator's registry crawls markdown AND HTML, while the
|
|
1248
|
+
* inventory walk has always been markdown-only. Sharing one registry between them
|
|
1249
|
+
* would quietly widen the link graph `files.linked` is computed from, which is a
|
|
1250
|
+
* behaviour change and not this fix's business. Two crawls per project root is
|
|
1251
|
+
* still two, not two per skill.
|
|
1252
|
+
*/
|
|
1253
|
+
const inventoryRegistryCache = new Map();
|
|
1254
|
+
/** Build (once per run, per root) the registry the plugin inventory link walk needs. */
|
|
1255
|
+
async function getOrCreateInventoryRegistry(projectRoot) {
|
|
1256
|
+
const cached = inventoryRegistryCache.get(projectRoot);
|
|
1257
|
+
if (cached !== undefined) {
|
|
1258
|
+
return cached;
|
|
1259
|
+
}
|
|
1260
|
+
const pending = crawlSkillLinkRegistry(projectRoot);
|
|
1261
|
+
inventoryRegistryCache.set(projectRoot, pending);
|
|
1262
|
+
return pending;
|
|
1263
|
+
}
|
|
1264
|
+
/**
|
|
1265
|
+
* `extractClaudePluginInventory` for a plugin directory, handing it the shared
|
|
1266
|
+
* inventory registry so each skill it walks does not re-parse the corpus.
|
|
1267
|
+
*/
|
|
1268
|
+
async function pluginInventoryAt(dir) {
|
|
1269
|
+
const root = findProjectRoot(dir) ?? dir;
|
|
1270
|
+
return extractClaudePluginInventory(dir, await getOrCreateInventoryRegistry(root));
|
|
1271
|
+
}
|
|
1109
1272
|
/**
|
|
1110
1273
|
* Reset all module-level audit caches. Must be called at the start of every
|
|
1111
1274
|
* independent audit invocation so in-process callers (CLI entrypoint AND
|
|
@@ -1114,6 +1277,8 @@ const gitTrackerCache = new Map();
|
|
|
1114
1277
|
*/
|
|
1115
1278
|
export function resetAuditCaches() {
|
|
1116
1279
|
gitTrackerCache.clear();
|
|
1280
|
+
resetPackagingRegistryCache();
|
|
1281
|
+
inventoryRegistryCache.clear();
|
|
1117
1282
|
resetProjectRootCaches();
|
|
1118
1283
|
resetLoadedConfigCache();
|
|
1119
1284
|
resetSkillDiscoveryCache();
|
|
@@ -1138,18 +1303,18 @@ async function getOrCreateGitTracker(gitRoot) {
|
|
|
1138
1303
|
* `dist/`), returns `{ gitRoot: null, gitTracker: null }` to disable gitignore
|
|
1139
1304
|
* filtering — the user's explicit intent takes precedence over .gitignore.
|
|
1140
1305
|
*/
|
|
1141
|
-
async function resolveScanContext(dirPath) {
|
|
1306
|
+
async function resolveScanContext(dirPath, locationRoot) {
|
|
1142
1307
|
const detectedRoot = gitFindRoot(dirPath);
|
|
1143
1308
|
if (detectedRoot === null) {
|
|
1144
|
-
return { gitRoot: null, gitTracker: null };
|
|
1309
|
+
return { gitRoot: null, gitTracker: null, locationRoot };
|
|
1145
1310
|
}
|
|
1146
1311
|
const tracker = await getOrCreateGitTracker(detectedRoot);
|
|
1147
1312
|
// If the scan root itself is gitignored (e.g. user targeted `dist/`), disable
|
|
1148
1313
|
// gitignore filtering so the user's explicit intent wins.
|
|
1149
1314
|
if (tracker.isIgnoredByActiveSet(dirPath)) {
|
|
1150
|
-
return { gitRoot: null, gitTracker: null };
|
|
1315
|
+
return { gitRoot: null, gitTracker: null, locationRoot };
|
|
1151
1316
|
}
|
|
1152
|
-
return { gitRoot: detectedRoot, gitTracker: tracker };
|
|
1317
|
+
return { gitRoot: detectedRoot, gitTracker: tracker, locationRoot };
|
|
1153
1318
|
}
|
|
1154
1319
|
/**
|
|
1155
1320
|
* Build the gitignore exclusion map for a set of entry paths.
|
|
@@ -1188,14 +1353,14 @@ function getSkipReason(entry, fullPath, resolvedBaseDir, gitIgnoredMap, isMatch)
|
|
|
1188
1353
|
}
|
|
1189
1354
|
return null;
|
|
1190
1355
|
}
|
|
1191
|
-
async function scanDirectory(dirPath, recursive, options, logger,
|
|
1356
|
+
async function scanDirectory(dirPath, recursive, options, logger, scanCtx, baseDir, nestedConfigLog) {
|
|
1192
1357
|
const fs = await import('node:fs/promises');
|
|
1193
1358
|
const results = [];
|
|
1194
1359
|
const userExcludes = options.exclude ?? [];
|
|
1195
1360
|
const resolvedBaseDir = baseDir ?? dirPath;
|
|
1196
|
-
//
|
|
1197
|
-
//
|
|
1198
|
-
const resolvedScanCtx = scanCtx
|
|
1361
|
+
// The git context (root + tracker) and the run's anchor base are built once by
|
|
1362
|
+
// the caller; every recursion reuses them unchanged.
|
|
1363
|
+
const resolvedScanCtx = scanCtx;
|
|
1199
1364
|
const resolvedNestedLog = nestedConfigLog ?? new Set();
|
|
1200
1365
|
// Top-down pre-warm of findProjectRoot's walk-up cache (spec §8). The first
|
|
1201
1366
|
// call here drives a single ancestor walk; every subsequent recursion is a
|
|
@@ -1256,76 +1421,48 @@ function calculateOverallStatus(results) {
|
|
|
1256
1421
|
return 'success';
|
|
1257
1422
|
}
|
|
1258
1423
|
/**
|
|
1259
|
-
*
|
|
1424
|
+
* How many results carry at least one finding.
|
|
1425
|
+
*
|
|
1426
|
+
* Counted from the results, not from the rendered hierarchy. The hierarchy is a
|
|
1427
|
+
* PROJECTION of the results — in `--verbose` it contains every scanned skill,
|
|
1428
|
+
* findings or not, so counting its entries reported "N with issues" equal to the
|
|
1429
|
+
* number scanned. The predicate itself is the only thing that answers this.
|
|
1260
1430
|
*/
|
|
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;
|
|
1431
|
+
function countResultsWithFindings(results) {
|
|
1432
|
+
return results.filter((r) => r.issues.length > 0).length;
|
|
1280
1433
|
}
|
|
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);
|
|
1434
|
+
/** Count files by their own status. */
|
|
1435
|
+
function countFilesByStatus(results) {
|
|
1291
1436
|
return {
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
totalWarnings,
|
|
1297
|
-
totalInfo,
|
|
1437
|
+
filesScanned: results.length,
|
|
1438
|
+
filesPassed: results.filter((r) => r.status === 'success').length,
|
|
1439
|
+
filesWithWarnings: results.filter((r) => r.status === 'warning').length,
|
|
1440
|
+
filesWithErrors: results.filter((r) => r.status === 'error').length,
|
|
1298
1441
|
};
|
|
1299
1442
|
}
|
|
1300
1443
|
/**
|
|
1301
1444
|
* Build base summary structure (used by both flat and hierarchical)
|
|
1445
|
+
*
|
|
1446
|
+
* `issueCounts` is named exactly as it is on every individual file entry, and
|
|
1447
|
+
* means the same thing at both levels: findings, by severity. `summary` only ever
|
|
1448
|
+
* counts files.
|
|
1302
1449
|
*/
|
|
1303
1450
|
function buildBaseSummary(results, startTime) {
|
|
1304
|
-
const counts = calculateIssueCounts(results);
|
|
1305
|
-
const status = calculateOverallStatus(results);
|
|
1306
1451
|
return {
|
|
1307
|
-
status,
|
|
1308
|
-
summary:
|
|
1309
|
-
|
|
1310
|
-
success: counts.successCount,
|
|
1311
|
-
warnings: counts.warningCount,
|
|
1312
|
-
errors: counts.errorCount,
|
|
1313
|
-
},
|
|
1314
|
-
issues: {
|
|
1315
|
-
errors: counts.totalErrors,
|
|
1316
|
-
warnings: counts.totalWarnings,
|
|
1317
|
-
info: counts.totalInfo,
|
|
1318
|
-
},
|
|
1452
|
+
status: calculateOverallStatus(results),
|
|
1453
|
+
summary: countFilesByStatus(results),
|
|
1454
|
+
issueCounts: countBySeverity(results.flatMap((r) => r.issues)),
|
|
1319
1455
|
duration: `${Date.now() - startTime}ms`,
|
|
1320
1456
|
};
|
|
1321
1457
|
}
|
|
1322
1458
|
/**
|
|
1323
1459
|
* Calculate summary for hierarchical output
|
|
1324
1460
|
*/
|
|
1325
|
-
function calculateHierarchicalSummary(results, hierarchical, startTime, compatMap, verbose) {
|
|
1461
|
+
function calculateHierarchicalSummary(results, hierarchical, startTime, compatMap, verbose, root) {
|
|
1326
1462
|
const base = buildBaseSummary(results, startTime);
|
|
1327
1463
|
const withCompat = applyCompatMap(results, compatMap);
|
|
1328
1464
|
return {
|
|
1465
|
+
root,
|
|
1329
1466
|
...base,
|
|
1330
1467
|
summary: {
|
|
1331
1468
|
...base.summary,
|
|
@@ -1334,16 +1471,16 @@ function calculateHierarchicalSummary(results, hierarchical, startTime, compatMa
|
|
|
1334
1471
|
standalonePlugins: hierarchical.standalonePlugins.length,
|
|
1335
1472
|
standaloneSkills: hierarchical.standaloneSkills.length,
|
|
1336
1473
|
},
|
|
1337
|
-
files: applyVerboseFilter(withCompat, verbose),
|
|
1474
|
+
files: relativizeFileEntries(applyVerboseFilter(withCompat, verbose), root),
|
|
1338
1475
|
hierarchical,
|
|
1339
1476
|
};
|
|
1340
1477
|
}
|
|
1341
1478
|
/**
|
|
1342
1479
|
* Log hierarchical summary to stderr
|
|
1343
1480
|
*/
|
|
1344
|
-
function logHierarchicalSummary(results,
|
|
1481
|
+
function logHierarchicalSummary(results, logger) {
|
|
1345
1482
|
const status = calculateOverallStatus(results);
|
|
1346
|
-
const skillsWithIssues =
|
|
1483
|
+
const skillsWithIssues = countResultsWithFindings(results);
|
|
1347
1484
|
const totalSkills = results.length;
|
|
1348
1485
|
// Audit is advisory only — always exit 0 for validation results.
|
|
1349
1486
|
// Use vat skills validate for gated validation (exit 1 on errors).
|
|
@@ -1355,6 +1492,11 @@ function logHierarchicalSummary(results, hierarchical, logger) {
|
|
|
1355
1492
|
const warningCount = results.filter((r) => r.status === 'warning').length;
|
|
1356
1493
|
logger.info(`Audit passed with warnings: ${warningCount} skill(s) (${totalSkills} scanned, ${skillsWithIssues} with issues)`);
|
|
1357
1494
|
}
|
|
1495
|
+
else if (skillsWithIssues > 0) {
|
|
1496
|
+
// `success` means "nothing actionable", NOT "nothing to see". Saying only
|
|
1497
|
+
// "N passed" over a pile of info findings is how they stayed invisible.
|
|
1498
|
+
logger.info(`Audit successful: ${totalSkills} skill(s) passed (${skillsWithIssues} with informational findings)`);
|
|
1499
|
+
}
|
|
1358
1500
|
else {
|
|
1359
1501
|
logger.info(`Audit successful: ${totalSkills} skill(s) passed`);
|
|
1360
1502
|
}
|