@vibe-agent-toolkit/cli 0.2.0-rc.5 → 0.2.0-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 -4
- package/dist/bin.js +41 -158
- package/dist/bin.js.map +1 -1
- package/dist/command-loaders.d.ts +2 -2
- package/dist/command-loaders.d.ts.map +1 -1
- package/dist/command-loaders.js +4 -2
- package/dist/command-loaders.js.map +1 -1
- package/dist/commands/ard/emit.d.ts +104 -0
- package/dist/commands/ard/emit.d.ts.map +1 -0
- package/dist/commands/ard/emit.js +223 -0
- package/dist/commands/ard/emit.js.map +1 -0
- package/dist/commands/ard/index.d.ts +12 -0
- package/dist/commands/ard/index.d.ts.map +1 -0
- package/dist/commands/ard/index.js +79 -0
- package/dist/commands/ard/index.js.map +1 -0
- package/dist/commands/ard/surfaces.d.ts +56 -0
- package/dist/commands/ard/surfaces.d.ts.map +1 -0
- package/dist/commands/ard/surfaces.js +150 -0
- package/dist/commands/ard/surfaces.js.map +1 -0
- package/dist/commands/audit.d.ts +75 -8
- package/dist/commands/audit.d.ts.map +1 -1
- package/dist/commands/audit.js +726 -127
- package/dist/commands/audit.js.map +1 -1
- package/dist/commands/claude/budget.d.ts +13 -0
- package/dist/commands/claude/budget.d.ts.map +1 -1
- package/dist/commands/claude/budget.js +80 -28
- package/dist/commands/claude/budget.js.map +1 -1
- package/dist/commands/claude/claude-config.d.ts.map +1 -1
- package/dist/commands/claude/claude-config.js +6 -0
- package/dist/commands/claude/claude-config.js.map +1 -1
- package/dist/commands/claude/context.js +1 -1
- package/dist/commands/claude/context.js.map +1 -1
- package/dist/commands/claude/marketplace/validate.d.ts +55 -22
- package/dist/commands/claude/marketplace/validate.d.ts.map +1 -1
- package/dist/commands/claude/marketplace/validate.js +380 -75
- package/dist/commands/claude/marketplace/validate.js.map +1 -1
- package/dist/commands/claude/org/helpers.d.ts +59 -1
- package/dist/commands/claude/org/helpers.d.ts.map +1 -1
- package/dist/commands/claude/org/helpers.js +58 -7
- package/dist/commands/claude/org/helpers.js.map +1 -1
- package/dist/commands/claude/org/index.d.ts.map +1 -1
- package/dist/commands/claude/org/index.js +24 -16
- package/dist/commands/claude/org/index.js.map +1 -1
- package/dist/commands/claude/org/skills.d.ts +509 -0
- package/dist/commands/claude/org/skills.d.ts.map +1 -1
- package/dist/commands/claude/org/skills.js +1364 -94
- package/dist/commands/claude/org/skills.js.map +1 -1
- package/dist/commands/claude/org/users.d.ts.map +1 -1
- package/dist/commands/claude/org/users.js +3 -1
- package/dist/commands/claude/org/users.js.map +1 -1
- package/dist/commands/claude/org/workspaces.d.ts.map +1 -1
- package/dist/commands/claude/org/workspaces.js +5 -2
- package/dist/commands/claude/org/workspaces.js.map +1 -1
- package/dist/commands/claude/plugin/build.d.ts +8 -0
- package/dist/commands/claude/plugin/build.d.ts.map +1 -1
- package/dist/commands/claude/plugin/build.js +16 -2
- package/dist/commands/claude/plugin/build.js.map +1 -1
- package/dist/commands/claude/plugin/tree-copy.d.ts +72 -0
- package/dist/commands/claude/plugin/tree-copy.d.ts.map +1 -1
- package/dist/commands/claude/plugin/tree-copy.js +215 -10
- package/dist/commands/claude/plugin/tree-copy.js.map +1 -1
- package/dist/commands/corpus/index.d.ts.map +1 -1
- package/dist/commands/corpus/index.js +3 -0
- package/dist/commands/corpus/index.js.map +1 -1
- package/dist/commands/corpus/report.d.ts +0 -1
- package/dist/commands/corpus/report.d.ts.map +1 -1
- package/dist/commands/corpus/report.js +0 -1
- package/dist/commands/corpus/report.js.map +1 -1
- package/dist/commands/corpus/runner.d.ts +58 -1
- package/dist/commands/corpus/runner.d.ts.map +1 -1
- package/dist/commands/corpus/runner.js +90 -15
- package/dist/commands/corpus/runner.js.map +1 -1
- package/dist/commands/corpus/scan.d.ts.map +1 -1
- package/dist/commands/corpus/scan.js +0 -1
- package/dist/commands/corpus/scan.js.map +1 -1
- package/dist/commands/doctor.d.ts +14 -0
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +96 -18
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/okf/index.d.ts +11 -0
- package/dist/commands/okf/index.d.ts.map +1 -0
- package/dist/commands/okf/index.js +102 -0
- package/dist/commands/okf/index.js.map +1 -0
- package/dist/commands/okf/validate.d.ts +82 -0
- package/dist/commands/okf/validate.d.ts.map +1 -0
- package/dist/commands/okf/validate.js +157 -0
- package/dist/commands/okf/validate.js.map +1 -0
- package/dist/commands/phase-utils.d.ts +19 -3
- package/dist/commands/phase-utils.d.ts.map +1 -1
- package/dist/commands/phase-utils.js +26 -5
- package/dist/commands/phase-utils.js.map +1 -1
- package/dist/commands/rag/index-command.d.ts +27 -2
- package/dist/commands/rag/index-command.d.ts.map +1 -1
- package/dist/commands/rag/index-command.js +44 -7
- package/dist/commands/rag/index-command.js.map +1 -1
- package/dist/commands/rag/index.d.ts.map +1 -1
- package/dist/commands/rag/index.js +2 -0
- package/dist/commands/rag/index.js.map +1 -1
- package/dist/commands/resources/check-progress.d.ts +3 -0
- package/dist/commands/resources/check-progress.d.ts.map +1 -1
- package/dist/commands/resources/check-progress.js +6 -0
- package/dist/commands/resources/check-progress.js.map +1 -1
- package/dist/commands/resources/check.d.ts +33 -4
- package/dist/commands/resources/check.d.ts.map +1 -1
- package/dist/commands/resources/check.js +131 -44
- package/dist/commands/resources/check.js.map +1 -1
- package/dist/commands/resources/index.d.ts.map +1 -1
- package/dist/commands/resources/index.js +41 -9
- package/dist/commands/resources/index.js.map +1 -1
- package/dist/commands/resources/query.d.ts +18 -1
- package/dist/commands/resources/query.d.ts.map +1 -1
- package/dist/commands/resources/query.js +29 -4
- package/dist/commands/resources/query.js.map +1 -1
- package/dist/commands/resources/validate.d.ts +29 -0
- package/dist/commands/resources/validate.d.ts.map +1 -1
- package/dist/commands/resources/validate.js +116 -13
- package/dist/commands/resources/validate.js.map +1 -1
- package/dist/commands/skill/review-checklist.d.ts.map +1 -1
- package/dist/commands/skill/review-checklist.js +35 -0
- package/dist/commands/skill/review-checklist.js.map +1 -1
- package/dist/commands/skill/review.d.ts.map +1 -1
- package/dist/commands/skill/review.js +9 -2
- package/dist/commands/skill/review.js.map +1 -1
- package/dist/commands/skill/test/configure.d.ts +37 -0
- package/dist/commands/skill/test/configure.d.ts.map +1 -1
- package/dist/commands/skill/test/configure.js +57 -12
- package/dist/commands/skill/test/configure.js.map +1 -1
- package/dist/commands/skill/test/run.d.ts.map +1 -1
- package/dist/commands/skill/test/run.js +21 -6
- package/dist/commands/skill/test/run.js.map +1 -1
- package/dist/commands/skills/build.d.ts.map +1 -1
- package/dist/commands/skills/build.js +16 -3
- package/dist/commands/skills/build.js.map +1 -1
- package/dist/commands/skills/list.d.ts +2 -1
- package/dist/commands/skills/list.d.ts.map +1 -1
- package/dist/commands/skills/list.js +31 -9
- package/dist/commands/skills/list.js.map +1 -1
- package/dist/commands/skills/skill-discovery.d.ts +40 -1
- package/dist/commands/skills/skill-discovery.d.ts.map +1 -1
- package/dist/commands/skills/skill-discovery.js +32 -3
- 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 +11 -3
- package/dist/commands/skills/validate-command.js.map +1 -1
- package/dist/commands/skills/validate.d.ts +66 -7
- package/dist/commands/skills/validate.d.ts.map +1 -1
- package/dist/commands/skills/validate.js +133 -29
- package/dist/commands/skills/validate.js.map +1 -1
- package/dist/commands/verify.d.ts +94 -3
- package/dist/commands/verify.d.ts.map +1 -1
- package/dist/commands/verify.js +192 -45
- package/dist/commands/verify.js.map +1 -1
- package/dist/skill-resolution/packaging-config.d.ts +49 -5
- package/dist/skill-resolution/packaging-config.d.ts.map +1 -1
- package/dist/skill-resolution/packaging-config.js +59 -19
- package/dist/skill-resolution/packaging-config.js.map +1 -1
- package/dist/skill-resolution/resolve-skill-reference.d.ts.map +1 -1
- package/dist/skill-resolution/resolve-skill-reference.js +6 -3
- package/dist/skill-resolution/resolve-skill-reference.js.map +1 -1
- package/dist/utils/command-error.d.ts +31 -0
- package/dist/utils/command-error.d.ts.map +1 -1
- package/dist/utils/command-error.js +45 -0
- 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 +22 -6
- package/dist/utils/config-loader.js.map +1 -1
- package/dist/utils/edge-lens-evaluation.d.ts +83 -0
- package/dist/utils/edge-lens-evaluation.d.ts.map +1 -0
- package/dist/utils/edge-lens-evaluation.js +110 -0
- package/dist/utils/edge-lens-evaluation.js.map +1 -0
- package/dist/utils/optional-backend.d.ts +5 -4
- package/dist/utils/optional-backend.d.ts.map +1 -1
- package/dist/utils/optional-backend.js +5 -4
- package/dist/utils/optional-backend.js.map +1 -1
- package/dist/utils/project-root-policy.d.ts +20 -0
- package/dist/utils/project-root-policy.d.ts.map +1 -1
- package/dist/utils/project-root-policy.js +32 -0
- package/dist/utils/project-root-policy.js.map +1 -1
- package/dist/utils/projection-query.d.ts +41 -4
- package/dist/utils/projection-query.d.ts.map +1 -1
- package/dist/utils/projection-query.js +44 -10
- package/dist/utils/projection-query.js.map +1 -1
- package/dist/utils/projection-store.d.ts +41 -15
- package/dist/utils/projection-store.d.ts.map +1 -1
- package/dist/utils/projection-store.js +65 -18
- package/dist/utils/projection-store.js.map +1 -1
- package/dist/utils/repeatable-option.d.ts +37 -0
- package/dist/utils/repeatable-option.d.ts.map +1 -0
- package/dist/utils/repeatable-option.js +39 -0
- package/dist/utils/repeatable-option.js.map +1 -0
- package/dist/utils/resource-loader.d.ts +4 -3
- package/dist/utils/resource-loader.d.ts.map +1 -1
- package/dist/utils/resource-loader.js +61 -30
- package/dist/utils/resource-loader.js.map +1 -1
- package/dist/utils/root-argv.d.ts +53 -0
- package/dist/utils/root-argv.d.ts.map +1 -0
- package/dist/utils/root-argv.js +215 -0
- package/dist/utils/root-argv.js.map +1 -0
- package/dist/utils/run-integrity.d.ts +95 -0
- package/dist/utils/run-integrity.d.ts.map +1 -0
- package/dist/utils/run-integrity.js +102 -0
- package/dist/utils/run-integrity.js.map +1 -0
- package/dist/utils/sqlite-experimental-warning.d.ts +69 -0
- package/dist/utils/sqlite-experimental-warning.d.ts.map +1 -0
- package/dist/utils/sqlite-experimental-warning.js +144 -0
- package/dist/utils/sqlite-experimental-warning.js.map +1 -0
- package/dist/utils/user-context-scanner.d.ts +8 -0
- package/dist/utils/user-context-scanner.d.ts.map +1 -1
- package/dist/utils/user-context-scanner.js +4 -0
- package/dist/utils/user-context-scanner.js.map +1 -1
- package/dist/utils/validate-help-files.d.ts.map +1 -1
- package/dist/utils/validate-help-files.js +9 -2
- package/dist/utils/validate-help-files.js.map +1 -1
- package/docs/audit.md +120 -7
- package/docs/doctor.md +6 -6
- package/docs/index.md +1 -1
- package/docs/rag.md +3 -1
- package/docs/resources.md +5 -1
- package/docs/skills.md +47 -62
- package/package.json +18 -18
package/dist/commands/audit.js
CHANGED
|
@@ -5,22 +5,25 @@
|
|
|
5
5
|
import * as fs from 'node:fs';
|
|
6
6
|
import { existsSync as fsExistsSync } from 'node:fs';
|
|
7
7
|
import { basename } from 'node:path';
|
|
8
|
-
import { crawlAndResolveRegistry, detectDeclaredButMissing, detectMarketplacePluginSourceMissing, resetPackagingRegistryCache, detectPresentButUndeclared, detectReferenceTargetMissing, detectResourceFormat, enumerateSurfaces, materializeIssue, validate, validateMarketplace, validateSkill, validateSkillForPackaging, } from '@vibe-agent-toolkit/agent-skills';
|
|
8
|
+
import { conventionalSuiteProbe, crawlAndResolveRegistry, detectDeclaredButMissing, detectMarketplacePluginSourceMissing, resetPackagingRegistryCache, detectPresentButUndeclared, detectReferenceTargetMissing, detectResourceFormat, enumerateSurfaces, materializeIssue, validate, validateMarketplace, validateSkill, validateSkillForPackaging, } from '@vibe-agent-toolkit/agent-skills';
|
|
9
9
|
import { analyzeCompatibility, checkSettingsCompatibility, detectSkillClaudePluginNameMismatch, crawlSkillLinkRegistry, extractClaudeMarketplaceInventory, extractClaudePluginInventory, getClaudeUserPaths, readEffectiveSettings, validatePlugin, } from '@vibe-agent-toolkit/claude-marketplace';
|
|
10
10
|
import { detectFormat } from '@vibe-agent-toolkit/discovery';
|
|
11
|
+
import { listingRefusalRemedy } from '@vibe-agent-toolkit/resources';
|
|
11
12
|
import { calculateValidationStatus, countBySeverity, } from '@vibe-agent-toolkit/schema';
|
|
12
13
|
import { findProjectRoot, isAbsolutePath, isFilesystemAccessError, issueLocation, resetProjectRootCaches, safePath, } from '@vibe-agent-toolkit/utils';
|
|
14
|
+
import { DirectoryListingRefusedError } from '@vibe-agent-toolkit/utils/crawl';
|
|
13
15
|
import { gitFindRoot, isGitUrl, parseGitUrl, } from '@vibe-agent-toolkit/utils/git';
|
|
14
16
|
import { Command } from 'commander';
|
|
15
17
|
import picomatch from 'picomatch';
|
|
16
18
|
import { resetSkillDiscoveryCache, resolveProjectDeclaredEvalSuites, resolveSkillPackagingConfig, stripValidationAllowForDisplay, } from '../skill-resolution/packaging-config.js';
|
|
17
19
|
import { handleCommandError } from '../utils/command-error.js';
|
|
18
20
|
import { ConfigLoadError, loadConfig, resetLoadedConfigCache, } from '../utils/config-loader.js';
|
|
19
|
-
import { countCollapsedFindings, formatCollapsedFindingsHint, formatIssueLines, formatSeverityBreakdown, issuesToRenderAtVerbosity, } from '../utils/issue-rendering.js';
|
|
21
|
+
import { countCollapsedFindings, formatCollapsedFindingsHint, formatIssueLines, formatSeverityBreakdown, issuesToRenderAtVerbosity, sumSeverityCounts, } from '../utils/issue-rendering.js';
|
|
20
22
|
import { resolveIssueSeverity } from '../utils/issue-severity.js';
|
|
21
23
|
import { createLogger } from '../utils/logger.js';
|
|
22
24
|
import { writeYamlOutput } from '../utils/output.js';
|
|
23
25
|
import { relativizePathEntries } from '../utils/relativize-paths.js';
|
|
26
|
+
import { nothingCheckedFinding } from '../utils/run-integrity.js';
|
|
24
27
|
import { mergeSkillPackagingConfig } from '../utils/skill-packaging-config.js';
|
|
25
28
|
import { renderSkillQualityFooter } from '../utils/skill-quality-footer.js';
|
|
26
29
|
import { computeConfigVerdicts } from '../utils/verdict-helpers.js';
|
|
@@ -29,7 +32,7 @@ import { withClonedRepo } from './audit/git-url-clone.js';
|
|
|
29
32
|
import { buildHierarchicalOutput } from './audit/hierarchical-output.js';
|
|
30
33
|
import { renderProvenanceHeader, rewritePathsInResults, } from './audit/provenance.js';
|
|
31
34
|
import { createAuditSettingsCommand } from './audit-settings.js';
|
|
32
|
-
import { discoverSkillsFromConfig } from './skills/skill-discovery.js';
|
|
35
|
+
import { discoverSkillsFromConfig, SKILLS_INCLUDE_REMEDY, } from './skills/skill-discovery.js';
|
|
33
36
|
/** Resource type constant for agent skills, avoiding duplicate string literals. */
|
|
34
37
|
const RESOURCE_TYPE_AGENT_SKILL = 'agent-skill';
|
|
35
38
|
/** Resource type constant for Claude plugins, avoiding duplicate string literals. */
|
|
@@ -51,7 +54,7 @@ const VAT_CONFIG_FILENAME = 'vibe-agent-toolkit.config.yaml';
|
|
|
51
54
|
*
|
|
52
55
|
* Returns `null` if `scanRoot` has no config or the config has no skills.
|
|
53
56
|
*/
|
|
54
|
-
async function buildVATProjectContext(scanRoot, logger) {
|
|
57
|
+
async function buildVATProjectContext(scanRoot, logger, locationRoot) {
|
|
55
58
|
const configPath = safePath.join(scanRoot, VAT_CONFIG_FILENAME);
|
|
56
59
|
// eslint-disable-next-line security/detect-non-literal-fs-filename -- scanRoot is a controlled parameter
|
|
57
60
|
if (!fs.existsSync(configPath)) {
|
|
@@ -69,7 +72,7 @@ async function buildVATProjectContext(scanRoot, logger) {
|
|
|
69
72
|
return null;
|
|
70
73
|
const skillConfigs = new Map();
|
|
71
74
|
try {
|
|
72
|
-
const discovered = await discoverSkillsFromConfig(config.skills, scanRoot);
|
|
75
|
+
const discovered = await discoverSkillsFromConfig(config.skills, scanRoot, degradingDiscovery(scanRoot, logger, locationRoot));
|
|
73
76
|
const { defaults, config: perSkillConfig } = config.skills;
|
|
74
77
|
for (const skill of discovered) {
|
|
75
78
|
const absPath = safePath.resolve(skill.sourcePath);
|
|
@@ -79,7 +82,16 @@ async function buildVATProjectContext(scanRoot, logger) {
|
|
|
79
82
|
logger.debug(`Config-aware audit: found ${discovered.length} skill(s) in ${scanRoot}`);
|
|
80
83
|
}
|
|
81
84
|
catch (error) {
|
|
82
|
-
|
|
85
|
+
// Discovery was handed the degrade policy above, so a refused listing never
|
|
86
|
+
// reaches here; one that does (a caller that switched it to `'refuse'`) is
|
|
87
|
+
// still degraded past through the same ledger rather than swallowed. It used
|
|
88
|
+
// to be `logger.debug` for ANY error, which is how a `chmod 000` directory
|
|
89
|
+
// under `skills.include` downgraded every skill to config-free validation with
|
|
90
|
+
// the directory named nowhere an operator without `--debug` could see it. A
|
|
91
|
+
// defect in discovery is not environmental and still fails loudly.
|
|
92
|
+
if (!(error instanceof DirectoryListingRefusedError))
|
|
93
|
+
throw error;
|
|
94
|
+
recordRefusedDiscovery(error, scanRoot, logger, locationRoot);
|
|
83
95
|
}
|
|
84
96
|
if (skillConfigs.size === 0) {
|
|
85
97
|
return null;
|
|
@@ -162,22 +174,287 @@ function packagingResultToValidationResult(skillPath, result, configTargets, loc
|
|
|
162
174
|
* depth, so crawling again reports one file twice, while omitting it for a
|
|
163
175
|
* standalone bundle is the blindness this parameter exists to end.
|
|
164
176
|
*/
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
177
|
+
/**
|
|
178
|
+
* Resolve a skill's nearest-ancestor `vibe-agent-toolkit.config.yaml`, treating
|
|
179
|
+
* one that cannot be LOADED as absent rather than as fatal.
|
|
180
|
+
*
|
|
181
|
+
* 🔑 Both audit lanes call this, and that is the point. They used to carry
|
|
182
|
+
* separate copies of the fork, and the copies disagreed: the single-target lane
|
|
183
|
+
* caught `ConfigLoadError` and fell back, the directory-scan lane let it throw.
|
|
184
|
+
* One unrecognized key in one nested config therefore aborted a whole-tree scan
|
|
185
|
+
* with exit 2 and zero skills audited, while auditing a single SKILL.md under
|
|
186
|
+
* that same config exited 0 — a verdict that depended on whether the argument
|
|
187
|
+
* was a file or its parent directory. Keeping the tolerance in ONE function is
|
|
188
|
+
* what stops the two answers drifting apart again.
|
|
189
|
+
*
|
|
190
|
+
* Degrading beats destroying, and silence is not the alternative — the policy
|
|
191
|
+
* `SCAN_PATH_UNREADABLE` states in its own registry docstring. So this warns
|
|
192
|
+
* rather than logging at debug, AND files a finding on the config path, because
|
|
193
|
+
* the warning goes to stderr while the report goes to stdout: a run that
|
|
194
|
+
* downgraded N skills to the much weaker config-free `validateSkill` and exited 0
|
|
195
|
+
* said nothing about it in the machine-readable artifact anyone actually diffs.
|
|
196
|
+
* That is the "detector silently disables itself" shape this code cites and was
|
|
197
|
+
* still doing.
|
|
198
|
+
*
|
|
199
|
+
* ⚠️ The filesystem question IS asked, and the answer changes what the operator
|
|
200
|
+
* is told. `config-loader.ts` preserves `cause` specifically so callers can ask
|
|
201
|
+
* `isFilesystemAccessError` and "decide whether to degrade or abort"; catching
|
|
202
|
+
* `ConfigLoadError` unconditionally never asked at all, so a `chmod 000` config
|
|
203
|
+
* and a typo produced the same sentence — and a permissions problem is not a
|
|
204
|
+
* broken config.
|
|
205
|
+
*
|
|
206
|
+
* 🔑 Asking it and still DEGRADING is deliberate, and was measured. Re-throwing
|
|
207
|
+
* so the per-entry guard anchors the finding is the obvious alternative and is
|
|
208
|
+
* worse: on a fixture with one unreadable config and one perfectly readable
|
|
209
|
+
* skill, the re-throw produced `filesScanned: 1, filesPassed: 0` with the finding
|
|
210
|
+
* anchored on `skills/demo/SKILL.md` and the skill never validated at all. On a
|
|
211
|
+
* real `--user` run that is hundreds of readable skills losing every check
|
|
212
|
+
* because one config is `chmod 000` — the exact "destroying" this lane exists to
|
|
213
|
+
* prevent, and a violation of the neighbouring promise that "a skill beside the
|
|
214
|
+
* unreadable path is validated exactly as if it were not there". The finding is
|
|
215
|
+
* filed on the CONFIG, which is the path that was actually refused, and the skill
|
|
216
|
+
* goes on to config-free validation.
|
|
217
|
+
*
|
|
218
|
+
* @returns the skill's display-safe packaging config, or `null` when there is
|
|
219
|
+
* no governing config OR when the one found could not be loaded.
|
|
220
|
+
*/
|
|
221
|
+
async function resolveGoverningConfig(skillPath, logger, locationRoot) {
|
|
222
|
+
// The walk-up the resolver is about to do (cached), so the degrade handler can
|
|
223
|
+
// name the config whose include reached the directory. `null` means no config
|
|
224
|
+
// governs this skill; the resolver answers the same and nothing can be refused.
|
|
225
|
+
const configRoot = findProjectRoot(safePath.resolve(skillPath, '..'));
|
|
226
|
+
if (configRoot === null)
|
|
227
|
+
return null;
|
|
170
228
|
let fullConfig;
|
|
171
229
|
try {
|
|
172
|
-
fullConfig = await resolveSkillPackagingConfig(skillPath);
|
|
230
|
+
fullConfig = await resolveSkillPackagingConfig(skillPath, degradingDiscovery(configRoot, logger, locationRoot));
|
|
173
231
|
}
|
|
174
232
|
catch (err) {
|
|
175
|
-
if (
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
233
|
+
if (err instanceof ConfigLoadError) {
|
|
234
|
+
recordUnloadableConfig(err, logger, locationRoot);
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
// Unreachable while the resolver is handed `degradingDiscovery` above — the
|
|
238
|
+
// crawl then enumerates around a refused directory instead of throwing — but
|
|
239
|
+
// kept as the fallback for the default policy: same two channels, the skill
|
|
240
|
+
// goes on config-free rather than the run aborting.
|
|
241
|
+
if (err instanceof DirectoryListingRefusedError) {
|
|
242
|
+
recordRefusedDiscovery(err, configRoot, logger, locationRoot);
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
throw err;
|
|
179
246
|
}
|
|
180
|
-
|
|
247
|
+
return fullConfig === null ? null : stripValidationAllowForDisplay(fullConfig);
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Configs this run has already degraded past — unloadable, or loaded but with a
|
|
251
|
+
* `skills.include` the crawl could not fully expand — keyed by the PATH that was
|
|
252
|
+
* refused (the config file, or the directory the crawl could not list), each
|
|
253
|
+
* holding the synthetic result that carries its finding into the report.
|
|
254
|
+
*
|
|
255
|
+
* 🚨 MODULE-scoped, and cleared by {@link resetAuditCaches}, exactly like
|
|
256
|
+
* `config-loader.ts`'s `warnedConfigPaths`. It used to be a `Set` threaded as a
|
|
257
|
+
* parameter, and the threading had a hole: `handleFileEntry` passed the scan's
|
|
258
|
+
* real log, but `validateSingleSkill` constructed a fresh `new Set()` on EVERY
|
|
259
|
+
* call — and `validatePluginSkillsViaInventory` calls it in a loop over every
|
|
260
|
+
* skill in a plugin. A 40-skill plugin under one unloadable config printed 40
|
|
261
|
+
* identical warnings. The integration fixture was a bare directory, which routes
|
|
262
|
+
* through `handleFileEntry`, so the "warns once, not once per skill" test passed
|
|
263
|
+
* while the lane that actually repeated was never exercised. A run-scoped ledger
|
|
264
|
+
* cannot be re-created per call by mistake; a parameter can.
|
|
265
|
+
*
|
|
266
|
+
* ⚠️ Keyed on the refused PATH (derived from `ConfigLoadError.projectRoot` or
|
|
267
|
+
* `DirectoryListingRefusedError.refusal.directory`), NOT on `err.message`. The
|
|
268
|
+
* old key was the message with a comment claiming it "names the offending config
|
|
269
|
+
* path" — true only for a Zod failure, which is what the fixture happened to be.
|
|
270
|
+
* A YAML SYNTAX error is thrown out of `yaml.parse` and wrapped as `Failed to
|
|
271
|
+
* load config: ${message}`, and `YAMLParseError.message` carries no filename at
|
|
272
|
+
* all, so two nested projects with the same copy-pasted broken config collapsed
|
|
273
|
+
* to ONE warning naming NEITHER.
|
|
274
|
+
*/
|
|
275
|
+
const unloadableConfigResults = new Map();
|
|
276
|
+
/**
|
|
277
|
+
* The RUN's conventional-suite probe — the memo behind "does
|
|
278
|
+
* `<skill-root>/evals/evals.json` exist?".
|
|
279
|
+
*
|
|
280
|
+
* Resolving one skill's test input asks that question of the SUBJECT and of
|
|
281
|
+
* every entry in `projectSkills`, so a probe minted per skill costs S questions
|
|
282
|
+
* per skill and S² per run over the same S paths. Measured with the lab on a
|
|
283
|
+
* 103-skill adopter: 10,815 probes over 103 distinct paths, half of the
|
|
284
|
+
* command's entire filesystem traffic.
|
|
285
|
+
*
|
|
286
|
+
* 🚨 MODULE-scoped and cleared by {@link resetAuditCaches}, for the same reason
|
|
287
|
+
* `unloadableConfigResults` above is: this command has TWO lanes that build a
|
|
288
|
+
* shared context — {@link validateSingleSkill} and {@link handleFileEntry} — and
|
|
289
|
+
* both build it PER SKILL, inside their own loops. A probe threaded as a
|
|
290
|
+
* parameter would be re-minted by whichever lane forgot to thread it, silently,
|
|
291
|
+
* and that is exactly how this stayed quadratic: the field existed and no caller
|
|
292
|
+
* set it.
|
|
293
|
+
*
|
|
294
|
+
* ⚠️ Its lifetime is the RUN, never longer. The answer is a filesystem snapshot,
|
|
295
|
+
* and a memo outliving the invocation would keep answering for a tree that has
|
|
296
|
+
* since changed — in the CLI entrypoint AND in an in-process test sharing a
|
|
297
|
+
* vitest worker.
|
|
298
|
+
*/
|
|
299
|
+
let runSuiteProbe;
|
|
300
|
+
/** The run's probe, minted on first use and dropped by {@link resetAuditCaches}. */
|
|
301
|
+
function suiteProbeForRun() {
|
|
302
|
+
runSuiteProbe ??= conventionalSuiteProbe();
|
|
303
|
+
return runSuiteProbe;
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Warn about — and file a finding for — a governing config this run could not
|
|
307
|
+
* load, once per config however many skills it governs.
|
|
308
|
+
*
|
|
309
|
+
* @param err - The load failure, carrying the project root that owns the config
|
|
310
|
+
* @param logger - Warnings go to stderr; the finding goes to the report
|
|
311
|
+
* @param locationRoot - The run's anchor base for the emitted location
|
|
312
|
+
*/
|
|
313
|
+
function recordUnloadableConfig(err, logger, locationRoot) {
|
|
314
|
+
const configPath = safePath.join(err.projectRoot, VAT_CONFIG_FILENAME);
|
|
315
|
+
if (unloadableConfigResults.has(configPath))
|
|
316
|
+
return;
|
|
317
|
+
unloadableConfigResults.set(configPath, unreadablePathResult(configPath, err, locationRoot));
|
|
318
|
+
// The filesystem answer changes the NOUN, because the two conditions need
|
|
319
|
+
// different actions from the operator: `chmod`/ownership on one side, an edit
|
|
320
|
+
// to the file on the other. Collapsing them into one sentence was the whole
|
|
321
|
+
// cost of never asking.
|
|
322
|
+
const why = isFilesystemAccessError(err) ? 'unreadable' : 'unloadable';
|
|
323
|
+
logger.warn(`Ignoring ${why} config at ${configPath}; skills it governs are validated`
|
|
324
|
+
+ ` config-free: ${err.message}`);
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Audit's discovery policy: enumerate AROUND a directory the crawl cannot list,
|
|
328
|
+
* report the gap, and keep every skill the crawl could see under its config.
|
|
329
|
+
*
|
|
330
|
+
* 🔑 This is what keeps ONE `chmod 000` sibling from costing a readable,
|
|
331
|
+
* declared skill every config-aware check. Discovery's default is to refuse —
|
|
332
|
+
* right for a build that must not ship a shorter bundle — but audit already
|
|
333
|
+
* validates "a skill beside the unreadable path exactly as if it were not
|
|
334
|
+
* there", and that promise is only kept if discovery degrades the same way the
|
|
335
|
+
* walk does. The refusal is recorded through the same ledger as an unloadable
|
|
336
|
+
* config, so it is warned about once and filed once however many skills ask.
|
|
337
|
+
*
|
|
338
|
+
* @param configRoot - The project root whose `skills.include` is being expanded
|
|
339
|
+
* @param logger - Warnings go to stderr; the finding goes to the report
|
|
340
|
+
* @param locationRoot - The run's anchor base for the emitted location
|
|
341
|
+
*/
|
|
342
|
+
function degradingDiscovery(configRoot, logger, locationRoot) {
|
|
343
|
+
return {
|
|
344
|
+
degrade: (refusal) => {
|
|
345
|
+
recordRefusedDiscovery(new DirectoryListingRefusedError(refusal, { root: configRoot, remedy: SKILLS_INCLUDE_REMEDY }), configRoot, logger, locationRoot);
|
|
346
|
+
},
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Audit's ruling for the registry crawl — the link graph `crawlAndResolveRegistry`
|
|
351
|
+
* builds over the whole project root for the config-aware lane: enumerate
|
|
352
|
+
* AROUND a directory it cannot list, file the gap, keep every readable document.
|
|
353
|
+
*
|
|
354
|
+
* The other half of {@link degradingDiscovery}, on the other crawl the
|
|
355
|
+
* config-aware lane makes. Every other verb hands this crawl `'refuse'` (a build
|
|
356
|
+
* must not ship a shorter bundle), and while the registry carried that ruling
|
|
357
|
+
* INSIDE itself `vat audit` on a tree with one `chmod 000` sibling exited 2
|
|
358
|
+
* with `status: error` and ZERO findings — issue #180's exact shape, from the
|
|
359
|
+
* crawl the fix for #180 never looked at. Audit's contract is the opposite:
|
|
360
|
+
* status describes what was found, exit describes whether the run completed.
|
|
361
|
+
*
|
|
362
|
+
* Recorded through the same per-directory ledger as a refused discovery, so a
|
|
363
|
+
* directory both crawls meet is warned about once and filed once; a directory
|
|
364
|
+
* only THIS crawl meets (the link graph covers the whole root, discovery only
|
|
365
|
+
* `skills.include`'s bases) gets its own sentence, naming the crawl that
|
|
366
|
+
* skipped it. The finding carries the projection's remedy — the same sentence
|
|
367
|
+
* the refuse ruling throws — because `--exclude` governs audit's own walk, not
|
|
368
|
+
* this crawl, and an ignore rule is the one knob that does.
|
|
369
|
+
*
|
|
370
|
+
* @param projectRoot - The root the link graph is crawled from
|
|
371
|
+
* @param logger - Warnings go to stderr; the finding goes to the report
|
|
372
|
+
* @param locationRoot - The run's anchor base for the emitted location
|
|
373
|
+
*/
|
|
374
|
+
function degradingRegistry(projectRoot, logger, locationRoot) {
|
|
375
|
+
return {
|
|
376
|
+
degrade: (refusal) => {
|
|
377
|
+
const err = new DirectoryListingRefusedError(refusal, { root: projectRoot, remedy: listingRefusalRemedy(projectRoot) });
|
|
378
|
+
recordRefusedListing(err, logger, locationRoot, () => `The link graph under ${projectRoot} skipped an unreadable directory; every document beneath it`
|
|
379
|
+
+ ` is missing from this audit's link and reference checks: ${err.message}`);
|
|
380
|
+
},
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* Warn about — and file a finding for — a directory that skill discovery under
|
|
385
|
+
* `configRoot` could not list, once per directory however many skills ask.
|
|
386
|
+
*
|
|
387
|
+
* The finding is anchored on the DIRECTORY, not the config: the config loaded
|
|
388
|
+
* fine, and the directory is the path the filesystem refused. In the directory
|
|
389
|
+
* lane the walk's own per-entry guard files the same code on the same location;
|
|
390
|
+
* {@link dedupeUnreadablePathResults} keeps one. The warning carries the crawl's
|
|
391
|
+
* own sentence, which already names the directory against the project root and
|
|
392
|
+
* the `skills.include` remedy.
|
|
393
|
+
*
|
|
394
|
+
* @param err - The refusal, naming the directory the crawl could not list
|
|
395
|
+
* @param configRoot - The project root whose `skills.include` reached it
|
|
396
|
+
* @param logger - Warnings go to stderr; the finding goes to the report
|
|
397
|
+
* @param locationRoot - The run's anchor base for the emitted location
|
|
398
|
+
*/
|
|
399
|
+
function recordRefusedDiscovery(err, configRoot, logger, locationRoot) {
|
|
400
|
+
const configPath = safePath.join(configRoot, VAT_CONFIG_FILENAME);
|
|
401
|
+
recordRefusedListing(err, logger, locationRoot, () => `Skill discovery under ${configPath} skipped an unreadable directory; any skill beneath it`
|
|
402
|
+
+ ` is missing from this audit: ${err.message}`);
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* The one ledger write for a refused listing, whichever crawl met it: file
|
|
406
|
+
* `SCAN_PATH_UNREADABLE` on the directory and warn once on stderr. Keyed on the
|
|
407
|
+
* refused directory, so the second crawl to meet it — discovery and the link
|
|
408
|
+
* graph both walk `skills/locked` in the directory lane — says nothing more.
|
|
409
|
+
*
|
|
410
|
+
* @param err - The refusal, naming the directory the crawl could not list
|
|
411
|
+
* @param logger - Warnings go to stderr; the finding goes to the report
|
|
412
|
+
* @param locationRoot - The run's anchor base for the emitted location
|
|
413
|
+
* @param warning - The stderr sentence, built only when this is the first record
|
|
414
|
+
*/
|
|
415
|
+
function recordRefusedListing(err, logger, locationRoot, warning) {
|
|
416
|
+
const directory = err.refusal.directory;
|
|
417
|
+
if (unloadableConfigResults.has(directory))
|
|
418
|
+
return;
|
|
419
|
+
unloadableConfigResults.set(directory, unreadablePathResult(directory, err, locationRoot));
|
|
420
|
+
logger.warn(warning());
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* The synthetic results for every config this run degraded past, so the report
|
|
424
|
+
* carries what the stderr warning says.
|
|
425
|
+
*
|
|
426
|
+
* Read, never drained: `resetAuditCaches()` owns the lifetime, and a drain would
|
|
427
|
+
* make the answer depend on which aggregation site ran first.
|
|
428
|
+
*/
|
|
429
|
+
function unloadableConfigFindings() {
|
|
430
|
+
return [...unloadableConfigResults.values()];
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* A run's results plus the findings for every config it degraded past, with each
|
|
434
|
+
* refused path reported once.
|
|
435
|
+
*
|
|
436
|
+
* Appended, not merged into a neighbour: a config the scan could not load, or a
|
|
437
|
+
* directory its discovery could not list, has no host result to hang off, and
|
|
438
|
+
* attributing it to a skill would name a file that was read fine. Without the
|
|
439
|
+
* append the ONLY record was a stderr warning, so a run that downgraded every
|
|
440
|
+
* skill under that config to the weaker config-free validator exited 0 with a
|
|
441
|
+
* report that said nothing about it. Deduped AFTER the append, because the
|
|
442
|
+
* directory lane's walk files its own `SCAN_PATH_UNREADABLE` on the very
|
|
443
|
+
* directory a refused discovery names — one fact, one finding, whichever lane
|
|
444
|
+
* met it first.
|
|
445
|
+
*/
|
|
446
|
+
function withDegradedConfigFindings(results) {
|
|
447
|
+
return dedupeUnreadablePathResults([...results, ...unloadableConfigFindings()]);
|
|
448
|
+
}
|
|
449
|
+
async function validateSingleSkill(skillPath, options, logger, locationRoot, crawlTree, isVATGenerated) {
|
|
450
|
+
// Config-aware validation: walk UP to the skill's nearest-ancestor
|
|
451
|
+
// vibe-agent-toolkit.config.yaml and apply the skill's packaging block. A
|
|
452
|
+
// config that cannot be loaded is treated as absent — see
|
|
453
|
+
// {@link resolveGoverningConfig}, which both audit lanes share so they cannot
|
|
454
|
+
// disagree about that again. The warn-once ledger is run-scoped module state,
|
|
455
|
+
// NOT a set minted here — a fresh `new Set()` on this line is what made a
|
|
456
|
+
// 40-skill plugin print 40 identical warnings.
|
|
457
|
+
const skillConfig = await resolveGoverningConfig(skillPath, logger, locationRoot);
|
|
181
458
|
if (skillConfig !== null) {
|
|
182
459
|
logger.debug(` Using config-aware validation for: ${skillPath}`);
|
|
183
460
|
const { gitTracker } = await resolveScanContext(safePath.resolve(skillPath), locationRoot, logger);
|
|
@@ -189,10 +466,18 @@ async function validateSingleSkill(skillPath, options, logger, locationRoot, cra
|
|
|
189
466
|
// The project's declared eval suites, memoized per config root, so this lane
|
|
190
467
|
// predicts the same bundle the packager produces: no skill's report counts a
|
|
191
468
|
// SIBLING skill's eval suite as an ordinary bundled file.
|
|
469
|
+
// Audit's ruling for the link-graph crawl — see {@link degradingRegistry}.
|
|
470
|
+
// Stated on the context too, for the fallback crawl `validateSkillForPackaging`
|
|
471
|
+
// makes when this registry does not cover the skill: one ruling, both crawls.
|
|
472
|
+
const unreadable = degradingRegistry(projectRoot, logger, locationRoot);
|
|
192
473
|
const sharedCtx = {
|
|
193
|
-
registry: await crawlAndResolveRegistry(projectRoot),
|
|
474
|
+
registry: await crawlAndResolveRegistry(projectRoot, { unreadable }),
|
|
475
|
+
unreadable,
|
|
194
476
|
locationRoot,
|
|
195
|
-
projectSkills: await resolveProjectDeclaredEvalSuites(skillPath),
|
|
477
|
+
projectSkills: await resolveProjectDeclaredEvalSuites(skillPath, degradingDiscovery(projectRoot, logger, locationRoot)),
|
|
478
|
+
// The RUN's probe, not this call's — see {@link runSuiteProbe}. This lane
|
|
479
|
+
// is called in a loop over every skill in a plugin.
|
|
480
|
+
suiteProbe: suiteProbeForRun(),
|
|
196
481
|
};
|
|
197
482
|
if (gitTracker !== null) {
|
|
198
483
|
sharedCtx.gitTracker = gitTracker;
|
|
@@ -249,6 +534,20 @@ function collect(value, previous) {
|
|
|
249
534
|
/**
|
|
250
535
|
* Create audit command
|
|
251
536
|
* Top-level command: vat audit [path]
|
|
537
|
+
*
|
|
538
|
+
* 🔑 **Two verdicts, deliberately.** The YAML `status` describes the FINDINGS;
|
|
539
|
+
* the exit code describes whether the RUN completed. `status: error` beside exit
|
|
540
|
+
* `0` is the correct pair for a tree with errors, and both facts are worth
|
|
541
|
+
* publishing — but `status` moves with the exit code in every other command of
|
|
542
|
+
* this CLI, so this one is the exception and the help text says so out loud (see
|
|
543
|
+
* the `Output` and `Exit Codes` sections below, and `docs/audit.md`).
|
|
544
|
+
*
|
|
545
|
+
* ⚠️ Do NOT "reconcile" the two by making one follow the other. Making the exit
|
|
546
|
+
* code follow `status` turns an advisory report into a gate — the thing
|
|
547
|
+
* `vat validate` already is, and the thing this command's published contract
|
|
548
|
+
* promises it is not. Making `status` follow the exit code deletes the only
|
|
549
|
+
* machine-readable verdict a CI consumer has, and a stamped `success` over a
|
|
550
|
+
* report full of errors is a stronger falsehood than the mismatch ever was.
|
|
252
551
|
*/
|
|
253
552
|
export function createAuditCommand() {
|
|
254
553
|
const audit = new Command('audit');
|
|
@@ -315,11 +614,26 @@ Description:
|
|
|
315
614
|
done
|
|
316
615
|
(See packages/cli/docs/audit.md for details.)
|
|
317
616
|
|
|
617
|
+
Output — two verdicts, and they answer different questions:
|
|
618
|
+
The YAML 'status' describes the FINDINGS. The exit code describes whether the
|
|
619
|
+
RUN completed. On a tree with errors that is 'status: error' beside exit 0,
|
|
620
|
+
and both are correct.
|
|
621
|
+
|
|
622
|
+
Note that 'status' means something narrower here than elsewhere in this CLI,
|
|
623
|
+
where it moves with the exit code. Gate CI on the report — 'status' and
|
|
624
|
+
'issueCounts' — never on this command's exit code.
|
|
625
|
+
|
|
626
|
+
A run that audited ZERO files is refused, not passed: 'status: error' with one
|
|
627
|
+
non-overridable RESOURCE_CHECK_BROKEN under a top-level 'issues:' (the claim is
|
|
628
|
+
about the run, so it is not a files[] row), still beside exit 0 — the run
|
|
629
|
+
completed; it just is not a verdict.
|
|
630
|
+
|
|
318
631
|
Validation Behavior:
|
|
319
632
|
Advisory only: audit surfaces all validation issues for inspection.
|
|
320
633
|
Unlike 'vat skills validate', audit:
|
|
321
634
|
- NEVER applies validation.allow (allowed codes are always shown)
|
|
322
|
-
- Respects validation.severity:
|
|
635
|
+
- Respects validation.severity: a code set to 'ignore' is hidden; warnings
|
|
636
|
+
and errors are both reported, each at its configured severity
|
|
323
637
|
- ALWAYS exits 0 for validation results (never gates on errors)
|
|
324
638
|
|
|
325
639
|
For gated validation (CI/CD), use: vat skills validate
|
|
@@ -364,14 +678,24 @@ Config-Aware Validation:
|
|
|
364
678
|
which prevents false warnings for links the build pipeline resolves.
|
|
365
679
|
|
|
366
680
|
Config-aware mode never applies validation.allow — audit always shows
|
|
367
|
-
all issues. validation.severity IS applied
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
681
|
+
all issues. validation.severity IS applied, from three scopes, least
|
|
682
|
+
specific first:
|
|
683
|
+
resources.validation.severity project-wide (the same dial
|
|
684
|
+
'vat resources validate' reads)
|
|
685
|
+
skills.defaults.validation.severity project-wide (skills, plugins and
|
|
686
|
+
marketplaces alike)
|
|
687
|
+
skills.config.<name>.validation... that one skill
|
|
371
688
|
|
|
372
689
|
Exit Codes:
|
|
373
|
-
0 - Always
|
|
374
|
-
|
|
690
|
+
0 - Always, when the audit completes — including when it reports
|
|
691
|
+
'status: error'. The findings are in the report; see Output above.
|
|
692
|
+
A missing or unrecognised path is UNKNOWN_FORMAT; an unreadable one,
|
|
693
|
+
or a governing config that cannot be loaded or whose skills.include
|
|
694
|
+
reaches an unreadable directory, is SCAN_PATH_UNREADABLE — readable
|
|
695
|
+
siblings are still validated, and the config's skills config-free.
|
|
696
|
+
2 - The audit could not run at all (--user with no Claude config dir,
|
|
697
|
+
a git URL that would not clone, an internal failure), so there is
|
|
698
|
+
no report to read.
|
|
375
699
|
|
|
376
700
|
Examples:
|
|
377
701
|
$ vat audit ./plugins/ # Audit recursively (default)
|
|
@@ -439,7 +763,7 @@ async function auditUserDirectories(recursive, options, startTime, logger) {
|
|
|
439
763
|
logger.error('Claude plugins/skills/marketplaces have not been installed yet.');
|
|
440
764
|
process.exit(2);
|
|
441
765
|
}
|
|
442
|
-
const
|
|
766
|
+
const scanned = [];
|
|
443
767
|
// The roots that exist, minus any one another already covers — see
|
|
444
768
|
// {@link userScanTargets}. `marketplaces/` lives inside `plugins/`.
|
|
445
769
|
const present = [
|
|
@@ -449,8 +773,12 @@ async function auditUserDirectories(recursive, options, startTime, logger) {
|
|
|
449
773
|
];
|
|
450
774
|
for (const target of userScanTargets(present, recursive)) {
|
|
451
775
|
logger.debug(`Auditing user-level resources at: ${target}`);
|
|
452
|
-
|
|
776
|
+
scanned.push(...await getValidationResults(target, recursive, options, logger, scanRoot));
|
|
453
777
|
}
|
|
778
|
+
// The `--user` report is assembled here rather than by `buildAuditReport`, so
|
|
779
|
+
// it needs its own append or the config findings would reach stderr and not the
|
|
780
|
+
// document — see the same call in `buildAuditReport`.
|
|
781
|
+
const results = withDegradedConfigFindings(scanned);
|
|
454
782
|
// Run compatibility analysis if --compat flag is set
|
|
455
783
|
const compatMap = options.compat
|
|
456
784
|
? await runCompatAnalysis(results, logger, scanRoot) // --settings not supported in --user mode
|
|
@@ -463,7 +791,7 @@ async function auditUserDirectories(recursive, options, startTime, logger) {
|
|
|
463
791
|
if (verbose) {
|
|
464
792
|
renderVerboseEvidence(results, scanRoot, logger);
|
|
465
793
|
}
|
|
466
|
-
logHierarchicalSummary(results, logger);
|
|
794
|
+
logHierarchicalSummary(results, summary.issues, logger);
|
|
467
795
|
}
|
|
468
796
|
/** Skill resource types that can have per-skill validation config. */
|
|
469
797
|
const SKILL_RESULT_TYPES = new Set([RESOURCE_TYPE_AGENT_SKILL, 'vat-agent']);
|
|
@@ -547,13 +875,21 @@ function buildFilteredResult(result, filteredIssues) {
|
|
|
547
875
|
* same config promoted correctly. Two lanes, one config key, opposite answers.
|
|
548
876
|
*
|
|
549
877
|
* Status and counts are re-derived from the resolved severities by
|
|
550
|
-
* {@link buildFilteredResult}, so a promotion moves the reported
|
|
551
|
-
*
|
|
552
|
-
*
|
|
553
|
-
*
|
|
554
|
-
*
|
|
555
|
-
*
|
|
556
|
-
*
|
|
878
|
+
* {@link buildFilteredResult}, so a promotion moves the reported STATUS — which
|
|
879
|
+
* describes the findings — and never the exit code, which describes whether the
|
|
880
|
+
* run completed. The two are different questions and audit publishes both; see
|
|
881
|
+
* {@link handleAuditResults} and the `Output` section of this command's help.
|
|
882
|
+
* `vat verify` derives its exit code from severity because it is a gate; this
|
|
883
|
+
* command is a report.
|
|
884
|
+
*
|
|
885
|
+
* THREE scopes, least specific first:
|
|
886
|
+
* - `resources.validation.severity` — the PROJECT-WIDE map `vat resources
|
|
887
|
+
* validate` already honours. Audit read only the two `skills.*` scopes below,
|
|
888
|
+
* so one dial in one config file produced opposite answers from the two
|
|
889
|
+
* commands: measured on a two-code fixture, `LINK_MISSING_TARGET: ignore` and
|
|
890
|
+
* `LINK_TO_NAVIGATION_FILE: error` written here moved neither finding in
|
|
891
|
+
* audit. It is applied FIRST — a `skills` scope naming the same code is the
|
|
892
|
+
* more specific statement and wins.
|
|
557
893
|
* - `skills.defaults.validation.severity` — the PROJECT-WIDE map, applied to
|
|
558
894
|
* every result this run produced, including `claude-plugin` and `marketplace`
|
|
559
895
|
* ones. It used to be applied only to skill-typed results, which left the
|
|
@@ -588,11 +924,13 @@ function buildFilteredResult(result, filteredIssues) {
|
|
|
588
924
|
* @returns New results array with ignored codes removed from issues
|
|
589
925
|
*/
|
|
590
926
|
function applySeverityFilter(results, config) {
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
const
|
|
927
|
+
const skillsConfig = config?.skills;
|
|
928
|
+
// NOT gated on `config.skills` existing. It was, and that made the resources
|
|
929
|
+
// dial below inert for exactly the projects that have only one — a repo of
|
|
930
|
+
// plugins declares no skills at all.
|
|
931
|
+
const resourceSeverity = config?.resources?.validation?.severity ?? {};
|
|
932
|
+
const defaultSeverity = skillsConfig?.defaults?.validation?.severity ?? {};
|
|
933
|
+
const projectSeverity = { ...resourceSeverity, ...defaultSeverity };
|
|
596
934
|
// `.filter` after `.map`, not `.map` alone: a synthetic unreadable-path result
|
|
597
935
|
// exists ONLY to carry its finding, so once the adopter has set
|
|
598
936
|
// `severity.SCAN_PATH_UNREADABLE: ignore` the result has nothing left to say.
|
|
@@ -604,9 +942,9 @@ function applySeverityFilter(results, config) {
|
|
|
604
942
|
const skillName = SKILL_RESULT_TYPES.has(result.type) ? result.metadata?.name : undefined;
|
|
605
943
|
const perSkillSeverity = skillName === undefined
|
|
606
944
|
? {}
|
|
607
|
-
: (skillsConfig
|
|
608
|
-
// Merge:
|
|
609
|
-
const effectiveSeverity = { ...
|
|
945
|
+
: (skillsConfig?.config?.[skillName]?.validation?.severity ?? {});
|
|
946
|
+
// Merge, least specific first: resources → skills.defaults → skills.config.<name>
|
|
947
|
+
const effectiveSeverity = { ...projectSeverity, ...perSkillSeverity };
|
|
610
948
|
if (Object.keys(effectiveSeverity).length === 0) {
|
|
611
949
|
return result;
|
|
612
950
|
}
|
|
@@ -669,7 +1007,18 @@ export async function buildAuditReport(scanPath, options, startTime, logger) {
|
|
|
669
1007
|
const recursive = options.recursive !== false;
|
|
670
1008
|
logger.debug(`Auditing resources at: ${scanPath}`);
|
|
671
1009
|
const scanRoot = deriveScanRoot(scanPath);
|
|
672
|
-
const
|
|
1010
|
+
const scanned = await getValidationResults(scanPath, recursive, options, logger, scanRoot);
|
|
1011
|
+
// Build config-aware context scoped to the single derived config root —
|
|
1012
|
+
// used by compat analysis to resolve the plugin-level `targets` union.
|
|
1013
|
+
// VAT's design: one config per project, no composition.
|
|
1014
|
+
//
|
|
1015
|
+
// BEFORE the report is assembled, not after: this is the lane that expands
|
|
1016
|
+
// `skills.include` even when the scan reached no SKILL.md under the config,
|
|
1017
|
+
// and a refusal it meets is recorded in the same ledger the append below
|
|
1018
|
+
// reads. Built afterwards, that finding reached stderr and not the document.
|
|
1019
|
+
const vatContextForCompat = await buildVATProjectContext(deriveConfigRoot(scanPath), logger, scanRoot);
|
|
1020
|
+
// See {@link withDegradedConfigFindings} for why these are appended, not merged.
|
|
1021
|
+
const rawResults = withDegradedConfigFindings(scanned);
|
|
673
1022
|
// Load config for severity filtering (audit ignores allow; only severity matters).
|
|
674
1023
|
//
|
|
675
1024
|
// Guarded, and the ordering is why: the scan above has ALREADY run and collected
|
|
@@ -699,10 +1048,6 @@ export async function buildAuditReport(scanPath, options, startTime, logger) {
|
|
|
699
1048
|
// Allow is deliberately NOT applied — audit is advisory only.
|
|
700
1049
|
const results = applySeverityFilter(rawResults, config);
|
|
701
1050
|
const effectiveSettings = await resolveEffectiveSettings(options, scanPath, logger);
|
|
702
|
-
// Build config-aware context scoped to the single derived config root —
|
|
703
|
-
// used by compat analysis to resolve the plugin-level `targets` union.
|
|
704
|
-
// VAT's design: one config per project, no composition.
|
|
705
|
-
const vatContextForCompat = await buildVATProjectContext(deriveConfigRoot(scanPath), logger);
|
|
706
1051
|
// Run compatibility analysis if --compat flag is set
|
|
707
1052
|
const compatMap = options.compat
|
|
708
1053
|
? await runCompatAnalysis(results, logger, scanRoot, effectiveSettings, vatContextForCompat)
|
|
@@ -886,20 +1231,34 @@ async function appendPluginInventoryToSurfaceResults(surfaceResults, scanPath, l
|
|
|
886
1231
|
}
|
|
887
1232
|
/**
|
|
888
1233
|
* Surface `inventory.parseErrors[]` as `PLUGIN_INVALID_JSON` findings on
|
|
889
|
-
* `result`, skipping the
|
|
890
|
-
*
|
|
891
|
-
*
|
|
1234
|
+
* `result`, skipping the entries another lane already files under their own
|
|
1235
|
+
* code:
|
|
1236
|
+
*
|
|
1237
|
+
* - the plugin.json manifest entry, covered by `validatePlugin`;
|
|
1238
|
+
* - every `SKILL.md` entry. The skill extractor records a SKILL.md it could
|
|
1239
|
+
* not open, could not parse, or could not link-walk on the plugin's
|
|
1240
|
+
* `parseErrors[]` — and each of those is the SKILL's finding, filed on the
|
|
1241
|
+
* skill by its own validation (`SCAN_PATH_UNREADABLE`,
|
|
1242
|
+
* `SKILL_MISSING_FRONTMATTER`) in both the plugin-target lane
|
|
1243
|
+
* (`validatePluginSkillsViaInventory`, over the same inventory) and the
|
|
1244
|
+
* directory lane (the walk reaches every SKILL.md beneath the plugin).
|
|
1245
|
+
* Re-filing it here was a second copy under a code that names a file it is
|
|
1246
|
+
* not (plugin.json) and a format it is not (JSON), at error severity beside
|
|
1247
|
+
* the skill lane's warning, and carrying the build host's absolute path.
|
|
1248
|
+
*
|
|
1249
|
+
* What remains — hooks/hooks.json, .mcp.json — is appended with error
|
|
1250
|
+
* severity, the message spelled scan-root-relative wherever it names the file.
|
|
892
1251
|
*/
|
|
893
1252
|
function appendInventoryParseErrors(result, inv, locationRoot) {
|
|
894
1253
|
const pluginJsonSuffix = safePath.join('.claude-plugin', 'plugin.json');
|
|
895
1254
|
const parseIssues = [];
|
|
896
1255
|
for (const err of inv.parseErrors) {
|
|
897
|
-
if (err.path.endsWith(pluginJsonSuffix))
|
|
1256
|
+
if (err.path.endsWith(pluginJsonSuffix) || basename(err.path) === 'SKILL.md')
|
|
898
1257
|
continue;
|
|
899
1258
|
parseIssues.push({
|
|
900
1259
|
severity: 'error',
|
|
901
1260
|
code: 'PLUGIN_INVALID_JSON',
|
|
902
|
-
message: err.message,
|
|
1261
|
+
message: withRelativePath(err.message, err.path, locationRoot),
|
|
903
1262
|
location: issueLocation(err.path, locationRoot),
|
|
904
1263
|
});
|
|
905
1264
|
}
|
|
@@ -940,7 +1299,7 @@ function appendInventoryParseErrors(result, inv, locationRoot) {
|
|
|
940
1299
|
*/
|
|
941
1300
|
export async function getValidationResults(scanPath, recursive, options, logger, locationRoot) {
|
|
942
1301
|
try {
|
|
943
|
-
return await validateAuditSubject(scanPath, recursive, options, logger, locationRoot);
|
|
1302
|
+
return dedupeUnreadablePathResults(await validateAuditSubject(scanPath, recursive, options, logger, locationRoot));
|
|
944
1303
|
}
|
|
945
1304
|
catch (error) {
|
|
946
1305
|
// Only the filesystem refusing the subject degrades; a defect in a validator
|
|
@@ -951,6 +1310,83 @@ export async function getValidationResults(scanPath, recursive, options, logger,
|
|
|
951
1310
|
return [unreadablePathResult(scanPath, error, locationRoot)];
|
|
952
1311
|
}
|
|
953
1312
|
}
|
|
1313
|
+
/**
|
|
1314
|
+
* One refused path, one `SCAN_PATH_UNREADABLE` — however many lanes met it.
|
|
1315
|
+
*
|
|
1316
|
+
* Two lanes can reach the same unreadable directory in one run: the directory
|
|
1317
|
+
* walk (`scanDirectory`, which files the synthetic {@link unreadablePathResult})
|
|
1318
|
+
* and the distributed-tree detector, which crawls a skill's own directory at any
|
|
1319
|
+
* depth and reports what it could not list as an issue ON the skill's result.
|
|
1320
|
+
* Their populations overlap, but neither contains the other: the walk skips
|
|
1321
|
+
* what `--exclude`, the project's `resources.exclude` and gitignore skip, and
|
|
1322
|
+
* does not descend at all under `--no-recursive` or when the SKILL.md is named
|
|
1323
|
+
* directly — the detector enters all of those. So "the walk already reports
|
|
1324
|
+
* everything under the scan root" is false in both directions, and a rule that
|
|
1325
|
+
* silenced one lane by geography would drop the refusal in exactly the cases
|
|
1326
|
+
* where it is the only report. The rule is instead keyed on the FACT: the
|
|
1327
|
+
* code and the location, which both lanes derive from the same directory and
|
|
1328
|
+
* the same anchor root.
|
|
1329
|
+
*
|
|
1330
|
+
* The hosted issue wins and the synthetic result is dropped, not the reverse:
|
|
1331
|
+
* the detector's copy says which SKILL's crawl was cut short, which the
|
|
1332
|
+
* synthetic result cannot, and it is the only copy the direct-target lane ever
|
|
1333
|
+
* produces — so keeping it makes the two lanes' reports the same shape. A
|
|
1334
|
+
* synthetic result that carried nothing but the duplicate is removed whole; one
|
|
1335
|
+
* that somehow carried more keeps its other issues (it cannot today, but the
|
|
1336
|
+
* filter says what it means rather than assuming).
|
|
1337
|
+
*
|
|
1338
|
+
* A THIRD lane files synthetic results too: skill discovery under a governing
|
|
1339
|
+
* config, which refuses a directory its `skills.include` cannot list and is
|
|
1340
|
+
* recorded through {@link recordRefusedDiscovery}. In the directory lane the
|
|
1341
|
+
* walk has already filed its own synthetic result on that same directory, so
|
|
1342
|
+
* two synthetic results can carry one fact. Among synthetics the FIRST wins and
|
|
1343
|
+
* later copies of the same location are dropped — the walk's copy carries the
|
|
1344
|
+
* raw errno, the discovery's copy the crawl's remedy sentence, and the stderr
|
|
1345
|
+
* warning carries the latter either way.
|
|
1346
|
+
*/
|
|
1347
|
+
function dedupeUnreadablePathResults(results) {
|
|
1348
|
+
const reported = new Set();
|
|
1349
|
+
for (const result of results) {
|
|
1350
|
+
if (result.type === 'unknown')
|
|
1351
|
+
continue;
|
|
1352
|
+
for (const issue of result.issues) {
|
|
1353
|
+
if (issue.code === 'SCAN_PATH_UNREADABLE')
|
|
1354
|
+
reported.add(issue.location ?? '');
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
return results.flatMap((result) => {
|
|
1358
|
+
if (result.type !== 'unknown')
|
|
1359
|
+
return [result];
|
|
1360
|
+
const kept = withoutHostedRefusals(result, reported);
|
|
1361
|
+
for (const survivor of kept) {
|
|
1362
|
+
for (const issue of survivor.issues) {
|
|
1363
|
+
if (issue.code === 'SCAN_PATH_UNREADABLE')
|
|
1364
|
+
reported.add(issue.location ?? '');
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
return kept;
|
|
1368
|
+
});
|
|
1369
|
+
}
|
|
1370
|
+
/**
|
|
1371
|
+
* A synthetic result minus the refusals some hosted result already carries —
|
|
1372
|
+
* as an empty array when nothing is left, so {@link dedupeUnreadablePathResults}
|
|
1373
|
+
* can `flatMap` it away.
|
|
1374
|
+
*/
|
|
1375
|
+
function withoutHostedRefusals(result, hosted) {
|
|
1376
|
+
const issues = result.issues.filter((issue) => !(issue.code === 'SCAN_PATH_UNREADABLE' && hosted.has(issue.location ?? '')));
|
|
1377
|
+
if (issues.length === 0)
|
|
1378
|
+
return [];
|
|
1379
|
+
if (issues.length === result.issues.length)
|
|
1380
|
+
return [result];
|
|
1381
|
+
const issueCounts = countBySeverity(issues);
|
|
1382
|
+
return [{
|
|
1383
|
+
...result,
|
|
1384
|
+
status: calculateValidationStatus(issues),
|
|
1385
|
+
summary: formatCountsSummary(issueCounts),
|
|
1386
|
+
issues,
|
|
1387
|
+
issueCounts,
|
|
1388
|
+
}];
|
|
1389
|
+
}
|
|
954
1390
|
async function validateAuditSubject(scanPath, recursive, options, logger, locationRoot) {
|
|
955
1391
|
const format = detectFormat(scanPath);
|
|
956
1392
|
// Special handling for direct SKILL.md file
|
|
@@ -1114,7 +1550,15 @@ function resolveConfigTargetsForPlugin(pluginDir, vatContext) {
|
|
|
1114
1550
|
return union.size === 0 ? undefined : [...union];
|
|
1115
1551
|
}
|
|
1116
1552
|
/**
|
|
1117
|
-
*
|
|
1553
|
+
* `message`, with `absolutePath` spelled root-relative wherever it appears.
|
|
1554
|
+
* An OS refusal names the path it refused, absolutely; every other path in
|
|
1555
|
+
* the document is relative to `root`, and this one leaks the build host.
|
|
1556
|
+
*/
|
|
1557
|
+
function withRelativePath(message, absolutePath, locationRoot) {
|
|
1558
|
+
return message.replaceAll(absolutePath, issueLocation(absolutePath, locationRoot) || '.');
|
|
1559
|
+
}
|
|
1560
|
+
/**
|
|
1561
|
+
* Run compatibility analysis on plugin results and return a map of path -> PluginCompatEntry.
|
|
1118
1562
|
* Non-plugin results are skipped silently.
|
|
1119
1563
|
* When effectiveSettings is provided, also runs settings conflict detection.
|
|
1120
1564
|
*
|
|
@@ -1135,67 +1579,99 @@ export async function runCompatAnalysis(results, logger, locationRoot, effective
|
|
|
1135
1579
|
for (const result of results) {
|
|
1136
1580
|
if (result.type !== RESOURCE_TYPE_CLAUDE_PLUGIN)
|
|
1137
1581
|
continue;
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
: { configTargets };
|
|
1144
|
-
const compat = await analyzeCompatibility(result.path, locationRoot, analyzeOptions);
|
|
1145
|
-
// Settings conflict detection (when --settings flag is used)
|
|
1146
|
-
if (effectiveSettings === undefined) {
|
|
1147
|
-
compatMap.set(result.path, compat);
|
|
1148
|
-
}
|
|
1149
|
-
else {
|
|
1150
|
-
try {
|
|
1151
|
-
const settingsConflicts = await checkSettingsCompatibility(result.path, effectiveSettings);
|
|
1152
|
-
compatMap.set(result.path, { ...compat, settingsConflicts });
|
|
1153
|
-
}
|
|
1154
|
-
catch (settingsErr) {
|
|
1155
|
-
logger.debug(`Settings compat check skipped for ${result.path}: ${String(settingsErr)}`);
|
|
1156
|
-
compatMap.set(result.path, compat);
|
|
1157
|
-
}
|
|
1158
|
-
}
|
|
1159
|
-
}
|
|
1160
|
-
catch (err) {
|
|
1161
|
-
// Log but do not fail the audit — compat analysis is best-effort
|
|
1162
|
-
logger.debug(`Compatibility analysis skipped for ${result.path}: ${String(err)}`);
|
|
1582
|
+
const entry = {
|
|
1583
|
+
compat: await analyzeOrExplain(result.path, locationRoot, vatContext, logger),
|
|
1584
|
+
};
|
|
1585
|
+
if (effectiveSettings !== undefined) {
|
|
1586
|
+
entry.settings = await settingsVerdict(result.path, effectiveSettings, locationRoot, logger);
|
|
1163
1587
|
}
|
|
1588
|
+
compatMap.set(result.path, entry);
|
|
1164
1589
|
}
|
|
1165
1590
|
return compatMap;
|
|
1166
1591
|
}
|
|
1592
|
+
/**
|
|
1593
|
+
* The analyzer's result for one plugin, or — when it threw — the reason, said
|
|
1594
|
+
* on stderr and carried into the document. Compat analysis is best-effort:
|
|
1595
|
+
* it never fails the audit, but a plugin it could not analyze is reported as
|
|
1596
|
+
* exactly that, never as a plugin with no `compatibility:` block.
|
|
1597
|
+
*/
|
|
1598
|
+
async function analyzeOrExplain(pluginDir, locationRoot, vatContext, logger) {
|
|
1599
|
+
try {
|
|
1600
|
+
logger.debug(`Running compatibility analysis for: ${pluginDir}`);
|
|
1601
|
+
const configTargets = resolveConfigTargetsForPlugin(pluginDir, vatContext);
|
|
1602
|
+
const analyzeOptions = configTargets === undefined ? undefined : { configTargets };
|
|
1603
|
+
return await analyzeCompatibility(pluginDir, locationRoot, analyzeOptions);
|
|
1604
|
+
}
|
|
1605
|
+
catch (err) {
|
|
1606
|
+
const refused = typeof err.path === 'string' ? err.path : undefined;
|
|
1607
|
+
const raw = err instanceof Error ? err.message : String(err);
|
|
1608
|
+
const reason = refused === undefined ? raw : withRelativePath(raw, refused, locationRoot);
|
|
1609
|
+
logger.warn(`Compatibility analysis could not run for ${issueLocation(pluginDir, locationRoot) || '.'}: ${reason}`);
|
|
1610
|
+
return { analyzed: false, reason };
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
/**
|
|
1614
|
+
* One plugin's settings verdict: the checker's conflicts and the paths it
|
|
1615
|
+
* never saw — and, when the checker itself fails, the plugin as the one thing
|
|
1616
|
+
* unchecked, rather than no `settings:` block at all.
|
|
1617
|
+
*
|
|
1618
|
+
* The checker failing used to be `logger.debug` and a result with no settings
|
|
1619
|
+
* block, so a plugin the check could not run on was indistinguishable from one
|
|
1620
|
+
* the operator never asked about. A run that asked for `--settings` and got no
|
|
1621
|
+
* answer for a plugin is told so on stderr and in the document.
|
|
1622
|
+
*/
|
|
1623
|
+
async function settingsVerdict(pluginDir, effectiveSettings, locationRoot, logger) {
|
|
1624
|
+
try {
|
|
1625
|
+
return await checkSettingsCompatibility(pluginDir, effectiveSettings);
|
|
1626
|
+
}
|
|
1627
|
+
catch (settingsErr) {
|
|
1628
|
+
const reason = settingsErr instanceof Error ? settingsErr.message : String(settingsErr);
|
|
1629
|
+
logger.warn(`Settings compatibility could not be checked for ${issueLocation(pluginDir, locationRoot) || '.'}: ${reason}`);
|
|
1630
|
+
return { conflicts: [], unchecked: [{ path: pluginDir, reason }] };
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
/**
|
|
1634
|
+
* The `settings:` block for one plugin, every path anchored at `locationRoot`.
|
|
1635
|
+
* `compatible` is derived from BOTH halves: zero conflicts over a skill the
|
|
1636
|
+
* check never read is not compatibility, it is silence.
|
|
1637
|
+
*/
|
|
1638
|
+
function renderSettingsBlock(check, locationRoot) {
|
|
1639
|
+
const settings = {
|
|
1640
|
+
compatible: check.conflicts.length === 0 && check.unchecked.length === 0,
|
|
1641
|
+
conflicts: check.conflicts,
|
|
1642
|
+
};
|
|
1643
|
+
if (check.unchecked.length > 0) {
|
|
1644
|
+
settings.unchecked = check.unchecked.map(({ path, reason }) => ({
|
|
1645
|
+
path: issueLocation(path, locationRoot) || '.',
|
|
1646
|
+
reason: withRelativePath(reason, path, locationRoot),
|
|
1647
|
+
}));
|
|
1648
|
+
}
|
|
1649
|
+
return settings;
|
|
1650
|
+
}
|
|
1167
1651
|
/**
|
|
1168
1652
|
* Merge compatibility analysis results into validation result output objects.
|
|
1169
1653
|
* Returns an array of plain objects ready for YAML serialization.
|
|
1170
|
-
*
|
|
1654
|
+
* A plugin the run asked `--settings` about gets a `settings:` block, whatever
|
|
1655
|
+
* the analyzer did.
|
|
1171
1656
|
*/
|
|
1172
|
-
function mergeCompatIntoResults(results, compatMap) {
|
|
1657
|
+
function mergeCompatIntoResults(results, compatMap, locationRoot) {
|
|
1173
1658
|
return results.map(r => {
|
|
1174
|
-
const
|
|
1175
|
-
if (
|
|
1659
|
+
const entry = compatMap.get(r.path);
|
|
1660
|
+
if (entry === undefined)
|
|
1176
1661
|
return r;
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
...r,
|
|
1182
|
-
compatibility: compatWithoutSettings,
|
|
1183
|
-
settings: {
|
|
1184
|
-
compatible: settingsConflicts.length === 0,
|
|
1185
|
-
conflicts: settingsConflicts,
|
|
1186
|
-
},
|
|
1187
|
-
};
|
|
1188
|
-
}
|
|
1189
|
-
return { ...r, compatibility: compat };
|
|
1662
|
+
const merged = { ...r, compatibility: entry.compat };
|
|
1663
|
+
if (entry.settings !== undefined)
|
|
1664
|
+
merged.settings = renderSettingsBlock(entry.settings, locationRoot);
|
|
1665
|
+
return merged;
|
|
1190
1666
|
});
|
|
1191
1667
|
}
|
|
1192
1668
|
/**
|
|
1193
1669
|
* Apply compatibility data to results if a compatMap is provided and non-empty.
|
|
1194
1670
|
* Returns the original results array when no compat data is available.
|
|
1195
1671
|
*/
|
|
1196
|
-
function applyCompatMap(results, compatMap) {
|
|
1672
|
+
function applyCompatMap(results, compatMap, locationRoot) {
|
|
1197
1673
|
if (compatMap !== undefined && compatMap.size > 0) {
|
|
1198
|
-
return mergeCompatIntoResults(results, compatMap);
|
|
1674
|
+
return mergeCompatIntoResults(results, compatMap, locationRoot);
|
|
1199
1675
|
}
|
|
1200
1676
|
return results;
|
|
1201
1677
|
}
|
|
@@ -1232,7 +1708,7 @@ function applyVerboseFilter(results, verbose) {
|
|
|
1232
1708
|
const NESTED_PATH_CARRIERS = ['linkedFiles'];
|
|
1233
1709
|
function calculateSummary(results, startTime, compatMap, verbose, root) {
|
|
1234
1710
|
const { entries, ...base } = buildBaseSummary(results, startTime);
|
|
1235
|
-
const withCompat = applyCompatMap(entries, compatMap);
|
|
1711
|
+
const withCompat = applyCompatMap(entries, compatMap, root);
|
|
1236
1712
|
return {
|
|
1237
1713
|
// Stated once, first, and the only absolute path in the document.
|
|
1238
1714
|
root,
|
|
@@ -1345,23 +1821,83 @@ function renderVerboseEvidence(results, root, logger) {
|
|
|
1345
1821
|
}
|
|
1346
1822
|
}
|
|
1347
1823
|
}
|
|
1824
|
+
/**
|
|
1825
|
+
* The stderr half of a run-level refusal, so the human channel says what the
|
|
1826
|
+
* document says. The document is derived in `buildBaseSummary`; this only
|
|
1827
|
+
* RENDERS it. Both stderr lanes call it first, because "Audit successful: 0
|
|
1828
|
+
* file(s) passed" over `status: error` is the exact disagreement the refusal
|
|
1829
|
+
* exists to end (invariant 6 of `run-integrity.ts`). Exit stays 0 — the run
|
|
1830
|
+
* completed — and the note says which command gates.
|
|
1831
|
+
*
|
|
1832
|
+
* @returns Whether a refusal was rendered, so the caller skips its verdict line
|
|
1833
|
+
*/
|
|
1834
|
+
function logRunIntegrity(runIssues, logger) {
|
|
1835
|
+
if (runIssues === undefined || runIssues.length === 0)
|
|
1836
|
+
return false;
|
|
1837
|
+
logger.error(`Audit is not a verdict: it audited 0 files ${ADVISORY_EXIT_NOTE}`);
|
|
1838
|
+
for (const issue of runIssues) {
|
|
1839
|
+
for (const line of formatIssueLines(issue, ' '))
|
|
1840
|
+
logger.error(line);
|
|
1841
|
+
}
|
|
1842
|
+
return true;
|
|
1843
|
+
}
|
|
1844
|
+
/**
|
|
1845
|
+
* The stderr line for the settings lane: how many conflicts it found and how
|
|
1846
|
+
* many paths it never compared, across every plugin in the document.
|
|
1847
|
+
*
|
|
1848
|
+
* 🚩 This read `f.compatibility?.settingsConflicts` — a field the renderer had
|
|
1849
|
+
* already moved out into the `settings:` block — so the count was 0 on every
|
|
1850
|
+
* run and the line never printed. It reads the rendered block now, the same
|
|
1851
|
+
* one the YAML carries, and it counts the unchecked half too: a run that
|
|
1852
|
+
* skipped a skill says so without `--debug`.
|
|
1853
|
+
*/
|
|
1854
|
+
function logSettingsTotals(files, logger) {
|
|
1855
|
+
let conflicts = 0;
|
|
1856
|
+
let unchecked = 0;
|
|
1857
|
+
// The compat lane's own unchecked paths — a plugin the analyzer could only
|
|
1858
|
+
// partly read is in the document under `compatibility.unchecked`, and this is
|
|
1859
|
+
// the one line that keeps it from being silent on the human channel while the
|
|
1860
|
+
// settings lane says its half. `analyzed: false` has no list: it is the
|
|
1861
|
+
// plugin-wide failure, and the `reason` line already says so.
|
|
1862
|
+
let compatUnchecked = 0;
|
|
1863
|
+
for (const { compatibility, settings } of files) {
|
|
1864
|
+
conflicts += settings?.conflicts.length ?? 0;
|
|
1865
|
+
unchecked += settings?.unchecked?.length ?? 0;
|
|
1866
|
+
if (compatibility !== undefined && 'unchecked' in compatibility)
|
|
1867
|
+
compatUnchecked += compatibility.unchecked.length;
|
|
1868
|
+
}
|
|
1869
|
+
if (conflicts > 0) {
|
|
1870
|
+
logger.error(`⚠ ${conflicts} settings conflict(s) found — see 'settings' section in YAML output`);
|
|
1871
|
+
}
|
|
1872
|
+
if (unchecked > 0) {
|
|
1873
|
+
logger.error(`⚠ ${unchecked} path(s) the settings check could not compare — see 'settings.unchecked' in YAML output`);
|
|
1874
|
+
}
|
|
1875
|
+
if (compatUnchecked > 0) {
|
|
1876
|
+
logger.error(`⚠ ${compatUnchecked} path(s) the compatibility analysis could not read — see 'compatibility.unchecked' in YAML output`);
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
1348
1879
|
function handleAuditResults(results, summary, logger, verbose) {
|
|
1349
1880
|
const { filesWithErrors: errorCount, filesWithWarnings: warningCount, filesPassed: successCount, } = summary.summary;
|
|
1350
|
-
|
|
1351
|
-
const totalSettingsConflicts = (summary.files ?? []).reduce((sum, f) => {
|
|
1352
|
-
return sum + (f.compatibility?.settingsConflicts?.length ?? 0);
|
|
1353
|
-
}, 0);
|
|
1354
|
-
if (totalSettingsConflicts > 0) {
|
|
1355
|
-
logger.error(`\u26a0 ${totalSettingsConflicts} settings conflict(s) found — see 'settings' section in YAML output`);
|
|
1356
|
-
}
|
|
1881
|
+
logSettingsTotals(summary.files ?? [], logger);
|
|
1357
1882
|
// 🔑 Audit is advisory only — always exit 0 for validation results, and the
|
|
1358
|
-
// WORDING has to
|
|
1359
|
-
//
|
|
1360
|
-
//
|
|
1361
|
-
//
|
|
1362
|
-
//
|
|
1363
|
-
//
|
|
1364
|
-
|
|
1883
|
+
// WORDING has to say so. This line used to read "Audit failed" on exit 0: an
|
|
1884
|
+
// adopter wiring `vat audit` into CI read a failure and got a green step.
|
|
1885
|
+
// "Found" is what actually happened, and the gate is `vat validate`, named
|
|
1886
|
+
// here rather than implied.
|
|
1887
|
+
//
|
|
1888
|
+
// ⚠️ That fixed the WORDING and nothing else. The document still says
|
|
1889
|
+
// `status: error` over exit 0 — measured on
|
|
1890
|
+
// `packages/agent-skills/test/fixtures/skill-files` — and that is the
|
|
1891
|
+
// published contract, not a leftover: `status` describes the FINDINGS, the
|
|
1892
|
+
// exit code describes whether the RUN completed. An earlier version of this
|
|
1893
|
+
// comment claimed all three signals had been reconciled; two of the three
|
|
1894
|
+
// had, and the third is not a signal to reconcile. Where a reader is told
|
|
1895
|
+
// which is which is {@link createAuditCommand}'s help — see its `Output`
|
|
1896
|
+
// section, and `docs/audit.md`.
|
|
1897
|
+
if (logRunIntegrity(summary.issues, logger)) {
|
|
1898
|
+
// Rendered above; there is no file verdict to print over zero files.
|
|
1899
|
+
}
|
|
1900
|
+
else if (errorCount > 0) {
|
|
1365
1901
|
logger.error(`Audit found ${errorCount} file(s) with errors ${ADVISORY_EXIT_NOTE}`);
|
|
1366
1902
|
logFindingsForStatus(results, 'error', summary.root, logger.error.bind(logger), verbose);
|
|
1367
1903
|
}
|
|
@@ -1547,18 +2083,34 @@ async function handleFileEntry(entry, fullPath, options, logger, scanCtx) {
|
|
|
1547
2083
|
// Config-aware: walk UP to the skill's nearest-ancestor config and apply
|
|
1548
2084
|
// ONLY that skill's declared packaging rules. Configs do not compose
|
|
1549
2085
|
// across VAT projects — audit does not merge rules across sibling configs.
|
|
1550
|
-
|
|
1551
|
-
|
|
2086
|
+
// Shared with the single-target lane, so one unloadable nested config
|
|
2087
|
+
// cannot abort this whole scan while that lane shrugs it off. The warn-once
|
|
2088
|
+
// ledger lives in `unloadableConfigResults` (module scope, cleared by
|
|
2089
|
+
// `resetAuditCaches`) rather than being threaded here, so BOTH lanes dedupe
|
|
2090
|
+
// against the same state — this one used to, the other minted a fresh set
|
|
2091
|
+
// per skill.
|
|
2092
|
+
const skillConfig = await resolveGoverningConfig(fullPath, logger, locationRoot);
|
|
1552
2093
|
if (skillConfig !== null) {
|
|
1553
2094
|
logger.debug(` Using config-aware validation for: ${fullPath}`);
|
|
1554
2095
|
// Thread the per-scan tracker into packaging validation so gitignore
|
|
1555
2096
|
// checks in the link-graph walk stay O(1).
|
|
1556
2097
|
// `projectSkills`: same project-wide test-input rule as the lane above,
|
|
1557
2098
|
// resolved through the same per-config-root memo.
|
|
1558
|
-
const projectSkills = await resolveProjectDeclaredEvalSuites(fullPath);
|
|
2099
|
+
const projectSkills = await resolveProjectDeclaredEvalSuites(fullPath, degradingDiscovery(findProjectRoot(safePath.resolve(fullPath, '..')) ?? locationRoot, logger, locationRoot));
|
|
2100
|
+
// `suiteProbe`: the RUN's, not this entry's — the directory walk reaches
|
|
2101
|
+
// this function once per SKILL.md it finds. See {@link runSuiteProbe}.
|
|
2102
|
+
const suiteProbe = suiteProbeForRun();
|
|
2103
|
+
// No registry is supplied on this lane, so `validateSkillForPackaging`
|
|
2104
|
+
// crawls one per project root through `crawlAndResolveRegistry` — under
|
|
2105
|
+
// audit's ruling, which is the ONLY reason one `chmod 000` sibling no
|
|
2106
|
+
// longer exits this whole run at 2 with zero findings. The root is the
|
|
2107
|
+
// one the validator itself derives (`findProjectRoot(dirname) ?? dirname`),
|
|
2108
|
+
// so the refuse sentence and the finding name the same crawl.
|
|
2109
|
+
const skillDir = safePath.resolve(fullPath, '..');
|
|
2110
|
+
const unreadable = degradingRegistry(findProjectRoot(skillDir) ?? skillDir, logger, locationRoot);
|
|
1559
2111
|
const sharedCtx = scanCtx.gitTracker === null
|
|
1560
|
-
? { locationRoot, projectSkills }
|
|
1561
|
-
: { gitTracker: scanCtx.gitTracker, locationRoot, projectSkills };
|
|
2112
|
+
? { locationRoot, projectSkills, suiteProbe, unreadable }
|
|
2113
|
+
: { gitTracker: scanCtx.gitTracker, locationRoot, projectSkills, suiteProbe, unreadable };
|
|
1562
2114
|
const packagingResult = await validateSkillForPackaging(fullPath, skillConfig, 'source', sharedCtx);
|
|
1563
2115
|
const configAware = packagingResultToValidationResult(fullPath, packagingResult, skillConfig.targets, locationRoot);
|
|
1564
2116
|
await appendDistributedTreeFindings(configAware, fullPath, locationRoot, ownsOwnCrawl(scanCtx, fullPath));
|
|
@@ -1637,7 +2189,17 @@ function resolveProjectExcludes(scanDir, logger) {
|
|
|
1637
2189
|
catch (err) {
|
|
1638
2190
|
// Audit is a bulk linter over trees it does not own; a broken governing
|
|
1639
2191
|
// config must not abort the scan. Say so rather than dropping it silently.
|
|
1640
|
-
|
|
2192
|
+
//
|
|
2193
|
+
// 🚨 `warn`, not `debug`. This comment already said "say so" while logging at
|
|
2194
|
+
// a level nobody sees without `--debug`, so what actually happened was the
|
|
2195
|
+
// silent drop it forbids — and the consequence is the one this function's own
|
|
2196
|
+
// docstring spells out: EVERY `resources.exclude` the project declared is
|
|
2197
|
+
// void, so a package that excludes its deliberately-broken eval fixtures has
|
|
2198
|
+
// them audited as production skills. That is findings APPEARING, not findings
|
|
2199
|
+
// disappearing, which is exactly the direction an operator will read as VAT
|
|
2200
|
+
// being wrong rather than as their config being broken.
|
|
2201
|
+
logger.warn(`Config at ${projectRoot} could not be read; every resources.exclude it declares is`
|
|
2202
|
+
+ ` dropped for this run, so excluded trees are audited as ordinary source: ${String(err)}`);
|
|
1641
2203
|
return null;
|
|
1642
2204
|
}
|
|
1643
2205
|
if (patterns.length === 0)
|
|
@@ -1721,6 +2283,8 @@ export function resetAuditCaches() {
|
|
|
1721
2283
|
resetGitTrackerCache();
|
|
1722
2284
|
resetPackagingRegistryCache();
|
|
1723
2285
|
inventoryRegistryCache.clear();
|
|
2286
|
+
unloadableConfigResults.clear();
|
|
2287
|
+
runSuiteProbe = undefined;
|
|
1724
2288
|
resetProjectRootCaches();
|
|
1725
2289
|
resetLoadedConfigCache();
|
|
1726
2290
|
resetSkillDiscoveryCache();
|
|
@@ -1820,10 +2384,14 @@ function unreadablePathResult(dirPath, error, locationRoot) {
|
|
|
1820
2384
|
// identical reason, already exists on the distributed-tree finding — see
|
|
1821
2385
|
// `anchoredTreeLocation` in ./audit/distributed-tree.ts.
|
|
1822
2386
|
const location = issueLocation(dirPath, locationRoot) || '.';
|
|
2387
|
+
// The OS names the path it refused absolutely; the report speaks scan-root
|
|
2388
|
+
// coordinates everywhere else, and an absolute path here is the build host's
|
|
2389
|
+
// `$HOME` in every CI log.
|
|
2390
|
+
const reason = withRelativePath(error instanceof Error ? error.message : String(error), dirPath, locationRoot);
|
|
1823
2391
|
const issues = [
|
|
1824
2392
|
materializeIssue('SCAN_PATH_UNREADABLE', {
|
|
1825
2393
|
location,
|
|
1826
|
-
detail: `${location}: ${
|
|
2394
|
+
detail: `${location}: ${reason}`,
|
|
1827
2395
|
}),
|
|
1828
2396
|
];
|
|
1829
2397
|
const issueCounts = countBySeverity(issues);
|
|
@@ -2012,6 +2580,24 @@ function countFilesByStatus(results) {
|
|
|
2012
2580
|
* also present in its owner's `issues`, so the nested list is a VIEW of records
|
|
2013
2581
|
* already counted, not extra records. `audit-report-coherence.integration.test.ts`
|
|
2014
2582
|
* pins that subset relation, because counting once is only correct while it holds.
|
|
2583
|
+
*
|
|
2584
|
+
* 🚨 **Zero files audited is an ERROR, not "an empty tree audits cleanly".**
|
|
2585
|
+
* `calculateOverallStatus([])` is `success`, so an existing directory holding
|
|
2586
|
+
* nothing auditable — moved plugins, a wrong subdirectory, an excluded tree, a
|
|
2587
|
+
* tree of files no lane recognises — published `status: success` beside
|
|
2588
|
+
* `filesScanned: 0`, and this command's own documentation tells CI to gate on
|
|
2589
|
+
* that `status`. Derived HERE, in the one builder both the directory lane
|
|
2590
|
+
* (`calculateSummary`) and the `--user` lane (`calculateHierarchicalSummary`)
|
|
2591
|
+
* pass through, so neither can publish a clean status over a zero denominator.
|
|
2592
|
+
* Through the shared mechanism in `run-integrity.ts`: one non-overridable
|
|
2593
|
+
* `RESOURCE_CHECK_BROKEN` at `error`, published under a top-level `issues` —
|
|
2594
|
+
* the claim is about the RUN, so it is not a `files[]` row and does not count
|
|
2595
|
+
* toward `filesScanned` — and counted in the header `issueCounts`, so the
|
|
2596
|
+
* identity becomes `issueCounts === Σ files[].issueCounts + Σ issues`.
|
|
2597
|
+
*
|
|
2598
|
+
* The exit code is NOT touched by this: `status` describes the findings and the
|
|
2599
|
+
* exit code describes whether the run completed, and this run completed. See
|
|
2600
|
+
* {@link createAuditCommand}.
|
|
2015
2601
|
*/
|
|
2016
2602
|
function buildBaseSummary(results, startTime) {
|
|
2017
2603
|
const issueCounts = { errors: 0, warnings: 0, info: 0 };
|
|
@@ -2022,11 +2608,21 @@ function buildBaseSummary(results, startTime) {
|
|
|
2022
2608
|
issueCounts.info += counts.info;
|
|
2023
2609
|
return { ...result, issueCounts: counts };
|
|
2024
2610
|
});
|
|
2611
|
+
const runIssues = nothingCheckedFinding(entries.length, entries.flatMap((entry) => entry.issues), () => 'The audit ran over 0 files, so this report is not a verdict: a tree with nothing'
|
|
2612
|
+
+ ' to audit produces the same counts as a clean one. The path resolved to a tree'
|
|
2613
|
+
+ ' holding no auditable file — usually a wrong subdirectory, plugins or skills that'
|
|
2614
|
+
+ ' have moved, an excluded tree, files no audit lane recognises, or `--no-recursive`'
|
|
2615
|
+
+ ' over a tree whose resources sit deeper. Point the command at the plugin,'
|
|
2616
|
+
+ ' marketplace, registry or SKILL.md tree it should read.');
|
|
2025
2617
|
return {
|
|
2026
2618
|
entries,
|
|
2027
|
-
|
|
2619
|
+
// Every run-integrity finding is `error` by construction (invariant 2 of
|
|
2620
|
+
// `run-integrity.ts`), so a non-empty refusal IS the status; otherwise the
|
|
2621
|
+
// files decide it, as before.
|
|
2622
|
+
status: runIssues.length > 0 ? 'error' : calculateOverallStatus(entries),
|
|
2028
2623
|
summary: countFilesByStatus(entries),
|
|
2029
|
-
issueCounts,
|
|
2624
|
+
issueCounts: sumSeverityCounts([issueCounts, countBySeverity(runIssues)]),
|
|
2625
|
+
...(runIssues.length === 0 ? {} : { issues: [...runIssues] }),
|
|
2030
2626
|
duration: `${Date.now() - startTime}ms`,
|
|
2031
2627
|
};
|
|
2032
2628
|
}
|
|
@@ -2035,7 +2631,7 @@ function buildBaseSummary(results, startTime) {
|
|
|
2035
2631
|
*/
|
|
2036
2632
|
function calculateHierarchicalSummary(results, hierarchical, startTime, compatMap, verbose, root) {
|
|
2037
2633
|
const { entries, ...base } = buildBaseSummary(results, startTime);
|
|
2038
|
-
const withCompat = applyCompatMap(entries, compatMap);
|
|
2634
|
+
const withCompat = applyCompatMap(entries, compatMap, root);
|
|
2039
2635
|
return {
|
|
2040
2636
|
root,
|
|
2041
2637
|
...base,
|
|
@@ -2053,13 +2649,16 @@ function calculateHierarchicalSummary(results, hierarchical, startTime, compatMa
|
|
|
2053
2649
|
/**
|
|
2054
2650
|
* Log hierarchical summary to stderr
|
|
2055
2651
|
*/
|
|
2056
|
-
function logHierarchicalSummary(results, logger) {
|
|
2652
|
+
function logHierarchicalSummary(results, runIssues, logger) {
|
|
2057
2653
|
const status = calculateOverallStatus(results);
|
|
2058
2654
|
const skillsWithIssues = countResultsWithFindings(results);
|
|
2059
2655
|
const totalSkills = results.length;
|
|
2060
2656
|
// Audit is advisory only — always exit 0 for validation results.
|
|
2061
2657
|
// Use vat skills validate for gated validation (exit 1 on errors).
|
|
2062
|
-
if (
|
|
2658
|
+
if (logRunIntegrity(runIssues, logger)) {
|
|
2659
|
+
// Rendered above; there is no skill verdict to print over zero skills.
|
|
2660
|
+
}
|
|
2661
|
+
else if (status === 'error') {
|
|
2063
2662
|
const errorCount = results.filter((r) => r.status === 'error').length;
|
|
2064
2663
|
logger.error(`Audit found ${errorCount} skill(s) with errors (${totalSkills} scanned, ${skillsWithIssues} with issues) ${ADVISORY_EXIT_NOTE}`);
|
|
2065
2664
|
}
|