@vibe-agent-toolkit/cli 0.1.41-rc.7 → 0.1.41
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/dist/bin/vat +24 -1
- package/dist/bin/vat.js +24 -1
- package/dist/bin/vat.js.map +1 -1
- package/dist/bin.js +23 -2
- package/dist/bin.js.map +1 -1
- package/dist/commands/agent/list.d.ts +11 -0
- package/dist/commands/agent/list.d.ts.map +1 -1
- package/dist/commands/agent/list.js +30 -11
- package/dist/commands/agent/list.js.map +1 -1
- package/dist/commands/audit/distributed-tree.d.ts +172 -0
- package/dist/commands/audit/distributed-tree.d.ts.map +1 -0
- package/dist/commands/audit/distributed-tree.js +319 -0
- package/dist/commands/audit/distributed-tree.js.map +1 -0
- package/dist/commands/audit.d.ts +69 -1
- package/dist/commands/audit.d.ts.map +1 -1
- package/dist/commands/audit.js +525 -174
- package/dist/commands/audit.js.map +1 -1
- package/dist/commands/build.d.ts +14 -0
- package/dist/commands/build.d.ts.map +1 -1
- package/dist/commands/build.js +103 -26
- package/dist/commands/build.js.map +1 -1
- package/dist/commands/claude/marketplace/git-publish.d.ts.map +1 -1
- package/dist/commands/claude/marketplace/git-publish.js +21 -3
- package/dist/commands/claude/marketplace/git-publish.js.map +1 -1
- package/dist/commands/claude/marketplace/validate.d.ts +125 -0
- package/dist/commands/claude/marketplace/validate.d.ts.map +1 -1
- package/dist/commands/claude/marketplace/validate.js +251 -44
- package/dist/commands/claude/marketplace/validate.js.map +1 -1
- package/dist/commands/claude/org/stubs.d.ts +9 -1
- package/dist/commands/claude/org/stubs.d.ts.map +1 -1
- package/dist/commands/claude/org/stubs.js +11 -3
- package/dist/commands/claude/org/stubs.js.map +1 -1
- package/dist/commands/claude/plugin/build.d.ts +34 -4
- package/dist/commands/claude/plugin/build.d.ts.map +1 -1
- package/dist/commands/claude/plugin/build.js +187 -16
- package/dist/commands/claude/plugin/build.js.map +1 -1
- package/dist/commands/claude/plugin/plugin-files.d.ts.map +1 -1
- package/dist/commands/claude/plugin/plugin-files.js +6 -2
- package/dist/commands/claude/plugin/plugin-files.js.map +1 -1
- package/dist/commands/claude/plugin/plugin-json-merge.d.ts.map +1 -1
- package/dist/commands/claude/plugin/plugin-json-merge.js +15 -0
- package/dist/commands/claude/plugin/plugin-json-merge.js.map +1 -1
- package/dist/commands/claude/plugin/tree-copy.d.ts +40 -0
- package/dist/commands/claude/plugin/tree-copy.d.ts.map +1 -1
- package/dist/commands/claude/plugin/tree-copy.js +100 -4
- package/dist/commands/claude/plugin/tree-copy.js.map +1 -1
- package/dist/commands/corpus/report.d.ts +22 -4
- package/dist/commands/corpus/report.d.ts.map +1 -1
- package/dist/commands/corpus/report.js +1 -0
- package/dist/commands/corpus/report.js.map +1 -1
- package/dist/commands/corpus/runner.d.ts +16 -1
- package/dist/commands/corpus/runner.d.ts.map +1 -1
- package/dist/commands/corpus/runner.js +40 -22
- package/dist/commands/corpus/runner.js.map +1 -1
- package/dist/commands/doctor.js +1 -1
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/inventory.d.ts +2 -0
- package/dist/commands/inventory.d.ts.map +1 -1
- package/dist/commands/inventory.js +45 -4
- package/dist/commands/inventory.js.map +1 -1
- package/dist/commands/phase-utils.d.ts +183 -14
- package/dist/commands/phase-utils.d.ts.map +1 -1
- package/dist/commands/phase-utils.js +294 -29
- package/dist/commands/phase-utils.js.map +1 -1
- package/dist/commands/positional-args.d.ts +36 -0
- package/dist/commands/positional-args.d.ts.map +1 -0
- package/dist/commands/positional-args.js +52 -0
- package/dist/commands/positional-args.js.map +1 -0
- package/dist/commands/rag/query-command.d.ts +40 -0
- package/dist/commands/rag/query-command.d.ts.map +1 -1
- package/dist/commands/rag/query-command.js +47 -20
- package/dist/commands/rag/query-command.js.map +1 -1
- package/dist/commands/resources/index.d.ts.map +1 -1
- package/dist/commands/resources/index.js +27 -9
- package/dist/commands/resources/index.js.map +1 -1
- package/dist/commands/resources/scan.d.ts +37 -0
- package/dist/commands/resources/scan.d.ts.map +1 -1
- package/dist/commands/resources/scan.js +47 -32
- package/dist/commands/resources/scan.js.map +1 -1
- package/dist/commands/resources/validate.d.ts +136 -1
- package/dist/commands/resources/validate.d.ts.map +1 -1
- package/dist/commands/resources/validate.js +105 -66
- 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 +5 -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 +7 -3
- package/dist/commands/skill/review.js.map +1 -1
- package/dist/commands/skill/test/run.d.ts +1 -1
- package/dist/commands/skill/test/run.d.ts.map +1 -1
- package/dist/commands/skill/test/run.js +8 -6
- package/dist/commands/skill/test/run.js.map +1 -1
- package/dist/commands/skills/build.d.ts +349 -11
- package/dist/commands/skills/build.d.ts.map +1 -1
- package/dist/commands/skills/build.js +901 -97
- package/dist/commands/skills/build.js.map +1 -1
- package/dist/commands/skills/command-helpers.d.ts +6 -10
- package/dist/commands/skills/command-helpers.d.ts.map +1 -1
- package/dist/commands/skills/command-helpers.js +6 -22
- package/dist/commands/skills/command-helpers.js.map +1 -1
- package/dist/commands/skills/list.d.ts +18 -0
- package/dist/commands/skills/list.d.ts.map +1 -1
- package/dist/commands/skills/list.js +41 -15
- package/dist/commands/skills/list.js.map +1 -1
- package/dist/commands/skills/package.d.ts +15 -0
- package/dist/commands/skills/package.d.ts.map +1 -1
- package/dist/commands/skills/package.js +30 -20
- package/dist/commands/skills/package.js.map +1 -1
- package/dist/commands/skills/scope-guard.d.ts +64 -0
- package/dist/commands/skills/scope-guard.d.ts.map +1 -0
- package/dist/commands/skills/scope-guard.js +86 -0
- package/dist/commands/skills/scope-guard.js.map +1 -0
- package/dist/commands/skills/validate-command.d.ts.map +1 -1
- package/dist/commands/skills/validate-command.js +41 -7
- package/dist/commands/skills/validate-command.js.map +1 -1
- package/dist/commands/skills/validate.d.ts +44 -3
- package/dist/commands/skills/validate.d.ts.map +1 -1
- package/dist/commands/skills/validate.js +217 -53
- package/dist/commands/skills/validate.js.map +1 -1
- package/dist/commands/validate.d.ts +18 -0
- package/dist/commands/validate.d.ts.map +1 -1
- package/dist/commands/validate.js +89 -68
- package/dist/commands/validate.js.map +1 -1
- package/dist/commands/verify.d.ts +113 -6
- package/dist/commands/verify.d.ts.map +1 -1
- package/dist/commands/verify.js +440 -123
- package/dist/commands/verify.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -1
- package/dist/index.js.map +1 -1
- package/dist/skill-resolution/index.d.ts +1 -1
- package/dist/skill-resolution/index.d.ts.map +1 -1
- package/dist/skill-resolution/index.js +1 -1
- package/dist/skill-resolution/index.js.map +1 -1
- package/dist/skill-resolution/packaging-config.d.ts +15 -2
- package/dist/skill-resolution/packaging-config.d.ts.map +1 -1
- package/dist/skill-resolution/packaging-config.js +39 -2
- package/dist/skill-resolution/packaging-config.js.map +1 -1
- package/dist/utils/command-error.d.ts +34 -1
- package/dist/utils/command-error.d.ts.map +1 -1
- package/dist/utils/command-error.js +32 -1
- package/dist/utils/command-error.js.map +1 -1
- package/dist/utils/help-loader.d.ts +6 -2
- package/dist/utils/help-loader.d.ts.map +1 -1
- package/dist/utils/help-loader.js +9 -16
- package/dist/utils/help-loader.js.map +1 -1
- package/dist/utils/issue-rendering.d.ts +130 -0
- package/dist/utils/issue-rendering.d.ts.map +1 -1
- package/dist/utils/issue-rendering.js +147 -0
- package/dist/utils/issue-rendering.js.map +1 -1
- package/dist/utils/issue-severity.d.ts +51 -0
- package/dist/utils/issue-severity.d.ts.map +1 -0
- package/dist/utils/issue-severity.js +63 -0
- package/dist/utils/issue-severity.js.map +1 -0
- package/dist/utils/output.d.ts +54 -5
- package/dist/utils/output.d.ts.map +1 -1
- package/dist/utils/output.js +111 -17
- package/dist/utils/output.js.map +1 -1
- package/dist/utils/relativize-paths.d.ts +45 -0
- package/dist/utils/relativize-paths.d.ts.map +1 -0
- package/dist/utils/relativize-paths.js +67 -0
- package/dist/utils/relativize-paths.js.map +1 -0
- package/dist/utils/resource-loader.d.ts +21 -2
- package/dist/utils/resource-loader.d.ts.map +1 -1
- package/dist/utils/resource-loader.js +85 -11
- package/dist/utils/resource-loader.js.map +1 -1
- package/dist/utils/skill-packaging-config.d.ts +22 -1
- package/dist/utils/skill-packaging-config.d.ts.map +1 -1
- package/dist/utils/skill-packaging-config.js +26 -0
- package/dist/utils/skill-packaging-config.js.map +1 -1
- package/dist/utils/verdict-helpers.d.ts +4 -3
- package/dist/utils/verdict-helpers.d.ts.map +1 -1
- package/dist/utils/verdict-helpers.js +6 -13
- package/dist/utils/verdict-helpers.js.map +1 -1
- package/dist/version.d.ts +22 -2
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +31 -2
- package/dist/version.js.map +1 -1
- package/docs/index.md +12 -0
- package/docs/rag.md +9 -4
- package/docs/resources.md +30 -14
- package/docs/skills.md +25 -7
- package/package.json +12 -12
package/dist/commands/audit.js
CHANGED
|
@@ -4,22 +4,26 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import * as fs from 'node:fs';
|
|
6
6
|
import { existsSync as fsExistsSync } from 'node:fs';
|
|
7
|
+
import { basename } from 'node:path';
|
|
7
8
|
import { calculateValidationStatus, countBySeverity, } from '@vibe-agent-toolkit/agent-schema';
|
|
8
9
|
import { crawlAndResolveRegistry, detectDeclaredButMissing, detectMarketplacePluginSourceMissing, resetPackagingRegistryCache, detectPresentButUndeclared, detectReferenceTargetMissing, detectResourceFormat, enumerateSurfaces, validate, validateMarketplace, validateSkill, validateSkillForPackaging, } from '@vibe-agent-toolkit/agent-skills';
|
|
9
10
|
import { analyzeCompatibility, checkSettingsCompatibility, detectSkillClaudePluginNameMismatch, crawlSkillLinkRegistry, extractClaudeMarketplaceInventory, extractClaudePluginInventory, getClaudeUserPaths, readEffectiveSettings, validatePlugin, } from '@vibe-agent-toolkit/claude-marketplace';
|
|
10
11
|
import { detectFormat } from '@vibe-agent-toolkit/discovery';
|
|
11
|
-
import { findProjectRoot, gitFindRoot,
|
|
12
|
+
import { findProjectRoot, gitFindRoot, isAbsolutePath, isGitUrl, issueLocation, parseGitUrl, resetProjectRootCaches, safePath, } from '@vibe-agent-toolkit/utils';
|
|
12
13
|
import { Command } from 'commander';
|
|
13
14
|
import picomatch from 'picomatch';
|
|
14
|
-
import { resetSkillDiscoveryCache, resolveSkillPackagingConfig, stripValidationAllowForDisplay, } from '../skill-resolution/packaging-config.js';
|
|
15
|
+
import { resetSkillDiscoveryCache, resolveProjectDeclaredEvalSuites, resolveSkillPackagingConfig, stripValidationAllowForDisplay, } from '../skill-resolution/packaging-config.js';
|
|
15
16
|
import { handleCommandError } from '../utils/command-error.js';
|
|
16
17
|
import { ConfigLoadError, loadConfig, resetLoadedConfigCache, } from '../utils/config-loader.js';
|
|
17
|
-
import {
|
|
18
|
+
import { countCollapsedFindings, formatCollapsedFindingsHint, formatIssueLines, formatSeverityBreakdown, issuesToRenderAtVerbosity, } from '../utils/issue-rendering.js';
|
|
19
|
+
import { resolveIssueSeverity } from '../utils/issue-severity.js';
|
|
18
20
|
import { createLogger } from '../utils/logger.js';
|
|
19
21
|
import { writeYamlOutput } from '../utils/output.js';
|
|
22
|
+
import { relativizePathEntries } from '../utils/relativize-paths.js';
|
|
20
23
|
import { mergeSkillPackagingConfig } from '../utils/skill-packaging-config.js';
|
|
21
24
|
import { renderSkillQualityFooter } from '../utils/skill-quality-footer.js';
|
|
22
25
|
import { computeConfigVerdicts } from '../utils/verdict-helpers.js';
|
|
26
|
+
import { crawlOwnsSubtree, distributedTreeFindings, getOrCreateGitTracker, isWithin, resetGitTrackerCache, } from './audit/distributed-tree.js';
|
|
23
27
|
import { withClonedRepo } from './audit/git-url-clone.js';
|
|
24
28
|
import { buildHierarchicalOutput } from './audit/hierarchical-output.js';
|
|
25
29
|
import { renderProvenanceHeader, rewritePathsInResults, } from './audit/provenance.js';
|
|
@@ -149,8 +153,15 @@ function packagingResultToValidationResult(skillPath, result, configTargets, loc
|
|
|
149
153
|
/**
|
|
150
154
|
* Validate a single SKILL.md file, using config-aware validation if available.
|
|
151
155
|
* Used for direct SKILL.md and vat-agent paths passed to `vat audit`.
|
|
156
|
+
*
|
|
157
|
+
* @param crawlTree Whether this call owns the presence-side crawl of the skill's
|
|
158
|
+
* directory (see {@link appendDistributedTreeFindings} below). REQUIRED
|
|
159
|
+
* and never defaulted, because the wrong answer is silent in both directions: a
|
|
160
|
+
* skill nested inside a plugin is already crawled by `validatePlugin` at any
|
|
161
|
+
* depth, so crawling again reports one file twice, while omitting it for a
|
|
162
|
+
* standalone bundle is the blindness this parameter exists to end.
|
|
152
163
|
*/
|
|
153
|
-
async function validateSingleSkill(skillPath, options, logger, locationRoot, isVATGenerated) {
|
|
164
|
+
async function validateSingleSkill(skillPath, options, logger, locationRoot, crawlTree, isVATGenerated) {
|
|
154
165
|
// Try config-aware validation: walk UP to the skill's nearest-ancestor
|
|
155
166
|
// vibe-agent-toolkit.config.yaml and apply the skill's packaging block. A
|
|
156
167
|
// broken governing config is tolerated here (audit is a bulk linter): log it
|
|
@@ -168,21 +179,27 @@ async function validateSingleSkill(skillPath, options, logger, locationRoot, isV
|
|
|
168
179
|
const skillConfig = fullConfig === null ? null : stripValidationAllowForDisplay(fullConfig);
|
|
169
180
|
if (skillConfig !== null) {
|
|
170
181
|
logger.debug(` Using config-aware validation for: ${skillPath}`);
|
|
171
|
-
const { gitTracker } = await resolveScanContext(safePath.resolve(skillPath), locationRoot);
|
|
182
|
+
const { gitTracker } = await resolveScanContext(safePath.resolve(skillPath), locationRoot, logger);
|
|
172
183
|
// Key the shared registry on the same project root the validator derives,
|
|
173
184
|
// so every skill under one root parses the corpus once for the whole run
|
|
174
185
|
// instead of once each.
|
|
175
186
|
const skillDir = safePath.resolve(skillPath, '..');
|
|
176
187
|
const projectRoot = findProjectRoot(skillDir) ?? skillDir;
|
|
188
|
+
// The project's declared eval suites, memoized per config root, so this lane
|
|
189
|
+
// predicts the same bundle the packager produces: no skill's report counts a
|
|
190
|
+
// SIBLING skill's eval suite as an ordinary bundled file.
|
|
177
191
|
const sharedCtx = {
|
|
178
192
|
registry: await crawlAndResolveRegistry(projectRoot),
|
|
179
193
|
locationRoot,
|
|
194
|
+
projectSkills: await resolveProjectDeclaredEvalSuites(skillPath),
|
|
180
195
|
};
|
|
181
196
|
if (gitTracker !== null) {
|
|
182
197
|
sharedCtx.gitTracker = gitTracker;
|
|
183
198
|
}
|
|
184
199
|
const packagingResult = await validateSkillForPackaging(skillPath, skillConfig, 'source', sharedCtx);
|
|
185
|
-
|
|
200
|
+
const configAware = packagingResultToValidationResult(skillPath, packagingResult, skillConfig.targets, locationRoot);
|
|
201
|
+
await appendDistributedTreeFindings(configAware, skillPath, locationRoot, crawlTree);
|
|
202
|
+
return configAware;
|
|
186
203
|
}
|
|
187
204
|
// Fallback: basic validation
|
|
188
205
|
const validateOptions = { skillPath, locationRoot };
|
|
@@ -192,7 +209,35 @@ async function validateSingleSkill(skillPath, options, logger, locationRoot, isV
|
|
|
192
209
|
if (isVATGenerated === true) {
|
|
193
210
|
validateOptions.isVATGenerated = true;
|
|
194
211
|
}
|
|
195
|
-
|
|
212
|
+
const result = await validateSkill(validateOptions);
|
|
213
|
+
await appendDistributedTreeFindings(result, skillPath, locationRoot, crawlTree);
|
|
214
|
+
return result;
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Crawl a DISTRIBUTED skill's own directory for agent-instruction files and
|
|
218
|
+
* append what it finds. Mutates `result`, like every other additive detector
|
|
219
|
+
* pass in this command.
|
|
220
|
+
*
|
|
221
|
+
* Which trees count as distributed — and why the config's declaration is NOT the
|
|
222
|
+
* discriminator any more — lives with the classifier in
|
|
223
|
+
* `./audit/distributed-tree.ts` (`classifyScannedSkillTree`). In short: a repository that never adopted
|
|
224
|
+
* VAT declares nothing, and an adopting repo's `include` globs never enumerate
|
|
225
|
+
* its drafts and fixtures, so "not declared" swept up two populations of
|
|
226
|
+
* ordinary repo source.
|
|
227
|
+
*
|
|
228
|
+
* Called from BOTH lanes — config-aware and wild — deliberately. It used to be
|
|
229
|
+
* reachable only from the wild one, which left the abandoned discriminator alive
|
|
230
|
+
* at the caller in its purest suppressing form: adding a `vibe-agent-toolkit.config.yaml`
|
|
231
|
+
* to a dotfiles repo removed findings from skills installed under `~/.claude`,
|
|
232
|
+
* because a config-aware match returned before the classifier was ever asked.
|
|
233
|
+
* Whether the project also declares a skill says nothing about where that skill
|
|
234
|
+
* lives, so it must not decide whether the question gets asked.
|
|
235
|
+
*
|
|
236
|
+
* @param crawlTree `false` when a plugin lane already crawled this subtree — see
|
|
237
|
+
* {@link validateSingleSkill}.
|
|
238
|
+
*/
|
|
239
|
+
async function appendDistributedTreeFindings(result, skillPath, locationRoot, crawlTree) {
|
|
240
|
+
appendIssues(result, await distributedTreeFindings(skillPath, locationRoot, crawlTree));
|
|
196
241
|
}
|
|
197
242
|
/**
|
|
198
243
|
* Collect repeated option values into an array (used for --exclude)
|
|
@@ -213,7 +258,12 @@ export function createAuditCommand() {
|
|
|
213
258
|
.option('--exclude <glob>', 'Exclude paths matching glob pattern (repeatable)', collect, [])
|
|
214
259
|
.option('--include-artifacts', 'Include gitignored paths (build artifacts, dependencies) that are excluded by default')
|
|
215
260
|
.option('--user', 'Audit user-level Claude resources (default: $CLAUDE_CONFIG_DIR or ~/.claude — scans plugins/, skills/, marketplaces/)')
|
|
216
|
-
|
|
261
|
+
// `-v` short form: `vat audit -v` used to print the version (the root
|
|
262
|
+
// `-v, --version` shadowed every subcommand), and after that alias was
|
|
263
|
+
// removed it exited 1 as an unknown option — while five sibling verbs
|
|
264
|
+
// accepted it. Advertising only the long form is what made the CHANGELOG's
|
|
265
|
+
// "`-v` means `--verbose` on every verb" claim untrue.
|
|
266
|
+
.option('-v, --verbose', 'Show all scanned resources, including those without issues')
|
|
217
267
|
.option('--warn-unreferenced-files', 'Warn about files not referenced in skill markdown')
|
|
218
268
|
.option('--compat', 'Run compatibility analysis for each plugin (shows claude-code, claude-cowork, claude-chat support)')
|
|
219
269
|
.option('--settings [file]', 'Check plugins against Claude settings (auto-discover or specify file; requires --compat)')
|
|
@@ -306,13 +356,17 @@ Gitignore-Aware Scanning:
|
|
|
306
356
|
Outside a git repository, no automatic exclusions apply.
|
|
307
357
|
|
|
308
358
|
Config-Aware Validation:
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
359
|
+
The governing vibe-agent-toolkit.config.yaml is found by walking UP from
|
|
360
|
+
the audited path, so a built bundle deep under dist/ is still governed by
|
|
361
|
+
its project's config. Audit uses that project's build settings
|
|
362
|
+
(linkFollowDepth, files, excludeReferencesFromBundle) to validate skills,
|
|
363
|
+
which prevents false warnings for links the build pipeline resolves.
|
|
313
364
|
|
|
314
365
|
Config-aware mode never applies validation.allow — audit always shows
|
|
315
|
-
all issues. validation.severity
|
|
366
|
+
all issues. validation.severity IS applied: a code set to 'ignore' is
|
|
367
|
+
hidden. skills.defaults.validation.severity applies project-wide (skills,
|
|
368
|
+
plugins and marketplaces alike); skills.config.<name>.validation.severity
|
|
369
|
+
layers on top for that skill.
|
|
316
370
|
|
|
317
371
|
Exit Codes:
|
|
318
372
|
0 - Always (even when validation errors are surfaced)
|
|
@@ -330,6 +384,37 @@ Examples:
|
|
|
330
384
|
`);
|
|
331
385
|
return audit;
|
|
332
386
|
}
|
|
387
|
+
/**
|
|
388
|
+
* Which of the Claude install roots a `--user` run actually walks.
|
|
389
|
+
*
|
|
390
|
+
* The three roots read as siblings and are NOT: `marketplacesDir` is
|
|
391
|
+
* `<claudeDir>/plugins/marketplaces`, i.e. INSIDE `pluginsDir`. A recursive walk
|
|
392
|
+
* of `plugins/` already reaches every installed marketplace, so scanning
|
|
393
|
+
* `marketplaces/` afterwards audited each marketplace-installed plugin and skill
|
|
394
|
+
* a SECOND time, in a second result, under the identical `path:`. On one real
|
|
395
|
+
* machine that turned 7 distinct agent-instruction files into 12 reported rows —
|
|
396
|
+
* and it inflates every finding class, not one code, because the whole subject
|
|
397
|
+
* is re-audited rather than one detector re-firing.
|
|
398
|
+
*
|
|
399
|
+
* The containment test is `isWithin`, the same one the provenance classifier
|
|
400
|
+
* uses, so "inside" means the same thing on both sides and on Windows.
|
|
401
|
+
*
|
|
402
|
+
* Gated on `recursive`, and that is not defensive dressing: under
|
|
403
|
+
* `--no-recursive` a walk of `plugins/` stops at its top level and never reaches
|
|
404
|
+
* `marketplaces/`, so dropping the nested root there would replace a double
|
|
405
|
+
* count with no coverage at all.
|
|
406
|
+
*
|
|
407
|
+
* Pure, and exported for that reason: the shape of the defect is "the list of
|
|
408
|
+
* roots was wrong", which is assertable as a list.
|
|
409
|
+
*
|
|
410
|
+
* @internal Exported for unit testing only — not part of the public CLI API.
|
|
411
|
+
*/
|
|
412
|
+
export function userScanTargets(candidates, recursive) {
|
|
413
|
+
const resolved = candidates.map((dir) => safePath.resolve(dir));
|
|
414
|
+
if (!recursive)
|
|
415
|
+
return resolved;
|
|
416
|
+
return resolved.filter((dir) => !resolved.some((other) => other !== dir && isWithin(other, dir)));
|
|
417
|
+
}
|
|
333
418
|
/**
|
|
334
419
|
* Handle --user audit: scans ~/.claude/plugins, ~/.claude/skills, ~/.claude/marketplaces
|
|
335
420
|
* and outputs hierarchical YAML. Calls process.exit() when done.
|
|
@@ -354,21 +439,20 @@ async function auditUserDirectories(recursive, options, startTime, logger) {
|
|
|
354
439
|
process.exit(2);
|
|
355
440
|
}
|
|
356
441
|
const results = [];
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
results.push(...await getValidationResults(marketplacesDir, recursive, options, logger, scanRoot));
|
|
442
|
+
// The roots that exist, minus any one another already covers — see
|
|
443
|
+
// {@link userScanTargets}. `marketplaces/` lives inside `plugins/`.
|
|
444
|
+
const present = [
|
|
445
|
+
...(pluginsDirExists ? [pluginsDir] : []),
|
|
446
|
+
...(skillsDirExists ? [skillsDir] : []),
|
|
447
|
+
...(marketplacesDirExists ? [marketplacesDir] : []),
|
|
448
|
+
];
|
|
449
|
+
for (const target of userScanTargets(present, recursive)) {
|
|
450
|
+
logger.debug(`Auditing user-level resources at: ${target}`);
|
|
451
|
+
results.push(...await getValidationResults(target, recursive, options, logger, scanRoot));
|
|
368
452
|
}
|
|
369
453
|
// Run compatibility analysis if --compat flag is set
|
|
370
454
|
const compatMap = options.compat
|
|
371
|
-
? await runCompatAnalysis(results, logger) // --settings not supported in --user mode
|
|
455
|
+
? await runCompatAnalysis(results, logger, scanRoot) // --settings not supported in --user mode
|
|
372
456
|
: undefined;
|
|
373
457
|
const verbose = options.verbose ?? false;
|
|
374
458
|
const skillResults = results.filter((r) => SKILL_RESULT_TYPES.has(r.type));
|
|
@@ -380,22 +464,6 @@ async function auditUserDirectories(recursive, options, startTime, logger) {
|
|
|
380
464
|
}
|
|
381
465
|
logHierarchicalSummary(results, logger);
|
|
382
466
|
}
|
|
383
|
-
/**
|
|
384
|
-
* Apply severity filtering to validation results.
|
|
385
|
-
*
|
|
386
|
-
* Audit is advisory only: it applies `validation.severity` to decide what to
|
|
387
|
-
* show, but deliberately ignores `validation.allow`. Codes configured as
|
|
388
|
-
* `severity: 'ignore'` are stripped from the result issues before rendering.
|
|
389
|
-
*
|
|
390
|
-
* The severity config is per-skill, keyed by skill name in
|
|
391
|
-
* `config.skills.config[skillName].validation.severity`.
|
|
392
|
-
* Defaults config (`config.skills.defaults.validation.severity`) is also
|
|
393
|
-
* checked as a fallback.
|
|
394
|
-
*
|
|
395
|
-
* @param results - Raw validation results from skill/plugin validators
|
|
396
|
-
* @param config - Parsed VATConfig (may be undefined if no config file)
|
|
397
|
-
* @returns New results array with ignored codes removed from issues
|
|
398
|
-
*/
|
|
399
467
|
/** Skill resource types that can have per-skill validation config. */
|
|
400
468
|
const SKILL_RESULT_TYPES = new Set([RESOURCE_TYPE_AGENT_SKILL, 'vat-agent']);
|
|
401
469
|
/**
|
|
@@ -425,17 +493,27 @@ export function deriveScanRoot(targetPath) {
|
|
|
425
493
|
/**
|
|
426
494
|
* Derive the project root directory for config loading.
|
|
427
495
|
*
|
|
428
|
-
*
|
|
429
|
-
*
|
|
496
|
+
* Walks UP from the audit target through {@link findProjectRoot} — the same
|
|
497
|
+
* discovery ladder (`vibe-agent-toolkit.config.yaml`, then `.git/`) that
|
|
498
|
+
* `resolveSkillPackagingConfig` uses per skill, so the two cannot disagree about
|
|
499
|
+
* which project governs a path.
|
|
500
|
+
*
|
|
501
|
+
* It used to guess by SHAPE instead: a directory argument resolved to ITSELF and
|
|
502
|
+
* a `SKILL.md` argument to exactly two levels up. Neither reaches a project root
|
|
503
|
+
* from a built bundle — `vat audit dist/skills/demo` looked for the config inside
|
|
504
|
+
* the bundle, found none, and `applySeverityFilter` early-returned. The effect was
|
|
505
|
+
* that `severity.PACKAGED_AGENT_INSTRUCTION_FILE: ignore` — the opt-out this very
|
|
506
|
+
* command's `fix` text tells the reader to write — did nothing whenever audit was
|
|
507
|
+
* pointed at the bundle rather than at the project. Measured: `warnings: 1` with
|
|
508
|
+
* the override and without it, from the same tree, while `vat audit .` on that
|
|
509
|
+
* same project honoured it.
|
|
510
|
+
*
|
|
511
|
+
* `null` (no config and no git anywhere above) falls back to the target itself,
|
|
512
|
+
* which is what the shape-based version returned for every path.
|
|
430
513
|
*/
|
|
431
514
|
function deriveConfigRoot(targetPath) {
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
}
|
|
435
|
-
if (targetPath.endsWith('SKILL.md')) {
|
|
436
|
-
return safePath.resolve(safePath.join(targetPath, '..', '..'));
|
|
437
|
-
}
|
|
438
|
-
return safePath.resolve(targetPath);
|
|
515
|
+
const start = targetPath === undefined ? process.cwd() : deriveScanRoot(targetPath);
|
|
516
|
+
return findProjectRoot(start) ?? start;
|
|
439
517
|
}
|
|
440
518
|
/**
|
|
441
519
|
* Build the recalculated status and summary after issues have been filtered.
|
|
@@ -454,16 +532,55 @@ function buildFilteredResult(result, filteredIssues) {
|
|
|
454
532
|
};
|
|
455
533
|
}
|
|
456
534
|
/**
|
|
457
|
-
* Apply severity
|
|
535
|
+
* Apply severity resolution to validation results.
|
|
458
536
|
*
|
|
459
537
|
* Audit is advisory only: it applies `validation.severity` to decide what to
|
|
460
|
-
* show, but deliberately ignores `validation.allow`.
|
|
461
|
-
*
|
|
538
|
+
* show and at what severity, but deliberately ignores `validation.allow`.
|
|
539
|
+
*
|
|
540
|
+
* **Both directions, via the one shared resolver.** This used to compute the
|
|
541
|
+
* merged override map correctly and then consult it for a single value —
|
|
542
|
+
* `issues.filter(i => effective[i.code] !== 'ignore')` — so `ignore` worked
|
|
543
|
+
* while `error`, `warning` and `info` fell through untouched. Measured end to
|
|
544
|
+
* end on a real adopter tree: `severity: error` at BOTH scopes left
|
|
545
|
+
* `severity: warning`, `errors: 0`, `status: warning`, while `vat verify` on the
|
|
546
|
+
* same config promoted correctly. Two lanes, one config key, opposite answers.
|
|
547
|
+
*
|
|
548
|
+
* Status and counts are re-derived from the resolved severities by
|
|
549
|
+
* {@link buildFilteredResult}, so a promotion moves the reported status. It does
|
|
550
|
+
* NOT move this command's exit code — `vat audit` is advisory and its exit-code
|
|
551
|
+
* behaviour is a separate, pre-existing question tracked in its own issue. That
|
|
552
|
+
* asymmetry with `vat verify` (whose exit code IS severity-derived) is
|
|
553
|
+
* deliberate here rather than overlooked.
|
|
554
|
+
*
|
|
555
|
+
* Two scopes, both live:
|
|
556
|
+
* - `skills.defaults.validation.severity` — the PROJECT-WIDE map, applied to
|
|
557
|
+
* every result this run produced, including `claude-plugin` and `marketplace`
|
|
558
|
+
* ones. It used to be applied only to skill-typed results, which left the
|
|
559
|
+
* opt-out inert for the population that motivated it: the false positive
|
|
560
|
+
* `PACKAGED_AGENT_INSTRUCTION_FILE` was kept at `warning` for — an official
|
|
561
|
+
* plugin's intentional scaffold template — is a finding on a PLUGIN tree, and
|
|
562
|
+
* no per-skill key can name it. Measured before the fix: a plugin result kept
|
|
563
|
+
* its warning with `severity: ignore` set at `skills.defaults`.
|
|
564
|
+
* - `skills.config.<name>.validation.severity` — layered on top, and only for a
|
|
565
|
+
* result that names a skill, since that key is keyed by skill name.
|
|
462
566
|
*
|
|
463
|
-
*
|
|
464
|
-
*
|
|
465
|
-
*
|
|
466
|
-
*
|
|
567
|
+
* KNOWN, DELIBERATELY NOT FIXED — per-skill scope cannot reach a finding nested
|
|
568
|
+
* inside a plugin tree, even when that finding IS attributable to a skill.
|
|
569
|
+
* `vat audit <plugin dir>` reports two findings: the plugin root's `CLAUDE.md`
|
|
570
|
+
* (attributable to no skill, which is the case the project-wide scope above was
|
|
571
|
+
* added for) and an `AGENTS.md` nested inside one of the plugin's OWN skills,
|
|
572
|
+
* which is attributable. Measured: neither moves under
|
|
573
|
+
* `skills.config.<that skill>` nor under `skills.config.<the plugin's name>`;
|
|
574
|
+
* only the project-wide `skills.defaults` moves them. The mechanism is the line
|
|
575
|
+
* below — a `claude-plugin` result is not in `SKILL_RESULT_TYPES`, so `skillName`
|
|
576
|
+
* is `undefined` and the per-skill map is never consulted, no matter which name
|
|
577
|
+
* the adopter writes.
|
|
578
|
+
*
|
|
579
|
+
* The consequence worth stating plainly: an adopter with ONE intentional
|
|
580
|
+
* agent-instruction file in ONE plugin has to silence the code project-wide in
|
|
581
|
+
* this lane, losing the detector everywhere else. The marketplace lane has the
|
|
582
|
+
* same project-wide-only limitation for a different reason, recorded there: both
|
|
583
|
+
* marketplace config schemas are `.strict()` with no `validation` key at all.
|
|
467
584
|
*
|
|
468
585
|
* @param results - Raw validation results from skill/plugin validators
|
|
469
586
|
* @param config - Parsed VATConfig (may be undefined if no config file)
|
|
@@ -476,10 +593,7 @@ function applySeverityFilter(results, config) {
|
|
|
476
593
|
const skillsConfig = config.skills;
|
|
477
594
|
const defaultSeverity = skillsConfig.defaults?.validation?.severity ?? {};
|
|
478
595
|
return results.map(result => {
|
|
479
|
-
|
|
480
|
-
return result;
|
|
481
|
-
}
|
|
482
|
-
const skillName = result.metadata?.name;
|
|
596
|
+
const skillName = SKILL_RESULT_TYPES.has(result.type) ? result.metadata?.name : undefined;
|
|
483
597
|
const perSkillSeverity = skillName === undefined
|
|
484
598
|
? {}
|
|
485
599
|
: (skillsConfig.config?.[skillName]?.validation?.severity ?? {});
|
|
@@ -488,11 +602,18 @@ function applySeverityFilter(results, config) {
|
|
|
488
602
|
if (Object.keys(effectiveSeverity).length === 0) {
|
|
489
603
|
return result;
|
|
490
604
|
}
|
|
491
|
-
const
|
|
492
|
-
|
|
605
|
+
const resolvedIssues = resolveIssueSeverity(result.issues, { severity: effectiveSeverity });
|
|
606
|
+
// Short-circuit only when NOTHING moved. Comparing lengths alone was the old
|
|
607
|
+
// test, and it is blind to the promote direction by construction: a resolved
|
|
608
|
+
// `error` keeps the array exactly as long as it was, so an early return on
|
|
609
|
+
// equal lengths discarded every re-severitied issue and reported the raw
|
|
610
|
+
// ones. The severities have to be compared too.
|
|
611
|
+
const unchanged = resolvedIssues.length === result.issues.length &&
|
|
612
|
+
resolvedIssues.every((issue, index) => issue.severity === result.issues[index]?.severity);
|
|
613
|
+
if (unchanged) {
|
|
493
614
|
return result;
|
|
494
615
|
}
|
|
495
|
-
return buildFilteredResult(result,
|
|
616
|
+
return buildFilteredResult(result, resolvedIssues);
|
|
496
617
|
});
|
|
497
618
|
}
|
|
498
619
|
/**
|
|
@@ -539,7 +660,7 @@ export async function buildAuditReport(scanPath, options, startTime, logger) {
|
|
|
539
660
|
const vatContextForCompat = await buildVATProjectContext(deriveConfigRoot(scanPath), logger);
|
|
540
661
|
// Run compatibility analysis if --compat flag is set
|
|
541
662
|
const compatMap = options.compat
|
|
542
|
-
? await runCompatAnalysis(results, logger, effectiveSettings, vatContextForCompat)
|
|
663
|
+
? await runCompatAnalysis(results, logger, scanRoot, effectiveSettings, vatContextForCompat)
|
|
543
664
|
: undefined;
|
|
544
665
|
const verbose = options.verbose ?? false;
|
|
545
666
|
return {
|
|
@@ -570,7 +691,7 @@ async function runAuditAtPath(scanPath, options, overrides = {}) {
|
|
|
570
691
|
if (verbose) {
|
|
571
692
|
renderVerboseEvidence(results, document.root, logger);
|
|
572
693
|
}
|
|
573
|
-
handleAuditResults(results, document, logger);
|
|
694
|
+
handleAuditResults(results, document, logger, verbose);
|
|
574
695
|
logger.debug(`Audit complete in ${Date.now() - startTime}ms`);
|
|
575
696
|
}
|
|
576
697
|
async function runUrlAudit(rawInput, options) {
|
|
@@ -622,7 +743,11 @@ export async function auditCommand(targetPath, options) {
|
|
|
622
743
|
*/
|
|
623
744
|
async function validateSurface(surface, options, logger, locationRoot) {
|
|
624
745
|
if (surface.type === RESOURCE_TYPE_AGENT_SKILL) {
|
|
625
|
-
|
|
746
|
+
// No crawl: this arm only runs for a MULTI-surface root, which in practice is
|
|
747
|
+
// skill-claude-plugin (SKILL.md beside .claude-plugin/plugin.json). The plugin
|
|
748
|
+
// surface below crawls that same directory at any depth, so crawling here
|
|
749
|
+
// would report every agent-instruction file in it a second time.
|
|
750
|
+
return validateSingleSkill(surface.path, options, logger, locationRoot, false);
|
|
626
751
|
}
|
|
627
752
|
if (surface.type === RESOURCE_TYPE_CLAUDE_PLUGIN) {
|
|
628
753
|
return validatePlugin(surface.path, { locationRoot });
|
|
@@ -749,13 +874,13 @@ export async function getValidationResults(scanPath, recursive, options, logger,
|
|
|
749
874
|
// Special handling for direct SKILL.md file
|
|
750
875
|
if (format === RESOURCE_TYPE_AGENT_SKILL) {
|
|
751
876
|
logger.debug('Detected single Agent Skill');
|
|
752
|
-
return [await validateSingleSkill(scanPath, options, logger, locationRoot)];
|
|
877
|
+
return [await validateSingleSkill(scanPath, options, logger, locationRoot, true)];
|
|
753
878
|
}
|
|
754
879
|
// Special handling for VAT agent: validate its SKILL.md
|
|
755
880
|
if (format === 'vat-agent') {
|
|
756
881
|
const skillPath = safePath.join(scanPath, 'SKILL.md');
|
|
757
882
|
logger.debug('Detected VAT agent, validating SKILL.md');
|
|
758
|
-
return [await validateSingleSkill(skillPath, options, logger, locationRoot, true)];
|
|
883
|
+
return [await validateSingleSkill(skillPath, options, logger, locationRoot, true, true)];
|
|
759
884
|
}
|
|
760
885
|
// Enumerate all manifest surfaces at the directory root. If multiple are
|
|
761
886
|
// present (e.g., skill-claude-plugin: SKILL.md + .claude-plugin/plugin.json),
|
|
@@ -813,14 +938,11 @@ export async function getValidationResults(scanPath, recursive, options, logger,
|
|
|
813
938
|
const stat = await fsp.stat(scanPath);
|
|
814
939
|
if (stat.isDirectory()) {
|
|
815
940
|
logger.debug('Scanning directory for resources');
|
|
816
|
-
//
|
|
817
|
-
//
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
? { ...options, exclude: [...(options.exclude ?? []), ...configExcludes] }
|
|
822
|
-
: options;
|
|
823
|
-
return scanDirectory(scanPath, recursive, mergedOptions, logger, await resolveScanContext(scanPath, locationRoot));
|
|
941
|
+
// The project's `resources.exclude` is applied by the scan context,
|
|
942
|
+
// on the project-root basis its globs were written against — NOT
|
|
943
|
+
// merged into `options.exclude`, whose patterns are relative to the
|
|
944
|
+
// directory the operator named. See resolveProjectExcludes.
|
|
945
|
+
return scanDirectory(scanPath, recursive, options, logger, await resolveScanContext(scanPath, locationRoot, logger));
|
|
824
946
|
}
|
|
825
947
|
}
|
|
826
948
|
catch {
|
|
@@ -850,7 +972,9 @@ async function validatePluginSkillsViaInventory(scanPath, options, logger, locat
|
|
|
850
972
|
continue;
|
|
851
973
|
}
|
|
852
974
|
logger.debug(` Validating plugin-bundled skill (inventory): ${skill.files.skillMd}`);
|
|
853
|
-
|
|
975
|
+
// No crawl: `validatePlugin` already scanned the whole plugin tree, nested
|
|
976
|
+
// skill directories included, so this lane would duplicate its findings.
|
|
977
|
+
results.push(await validateSingleSkill(skill.files.skillMd, options, logger, locationRoot, false));
|
|
854
978
|
}
|
|
855
979
|
return results;
|
|
856
980
|
}
|
|
@@ -895,6 +1019,11 @@ function resolveConfigTargetsForPlugin(pluginDir, vatContext) {
|
|
|
895
1019
|
* Non-plugin results are skipped silently.
|
|
896
1020
|
* When effectiveSettings is provided, also runs settings conflict detection.
|
|
897
1021
|
*
|
|
1022
|
+
* `locationRoot` is the invocation scan root: the ONE base every emitted
|
|
1023
|
+
* evidence `location.file` is relative to. One audit run spans many plugin
|
|
1024
|
+
* directories, so anchoring evidence at each plugin would mix coordinate
|
|
1025
|
+
* systems in a single document.
|
|
1026
|
+
*
|
|
898
1027
|
* When vatContext is provided, config-layer `targets` declarations are
|
|
899
1028
|
* threaded through to the analyzer so `vat audit .` inside a VAT project
|
|
900
1029
|
* matches `vat skills validate` verdicts. See
|
|
@@ -902,7 +1031,7 @@ function resolveConfigTargetsForPlugin(pluginDir, vatContext) {
|
|
|
902
1031
|
*
|
|
903
1032
|
* @internal Exported for integration testing only — not part of the public CLI API.
|
|
904
1033
|
*/
|
|
905
|
-
export async function runCompatAnalysis(results, logger, effectiveSettings, vatContext = null) {
|
|
1034
|
+
export async function runCompatAnalysis(results, logger, locationRoot, effectiveSettings, vatContext = null) {
|
|
906
1035
|
const compatMap = new Map();
|
|
907
1036
|
for (const result of results) {
|
|
908
1037
|
if (result.type !== RESOURCE_TYPE_CLAUDE_PLUGIN)
|
|
@@ -913,7 +1042,7 @@ export async function runCompatAnalysis(results, logger, effectiveSettings, vatC
|
|
|
913
1042
|
const analyzeOptions = configTargets === undefined
|
|
914
1043
|
? undefined
|
|
915
1044
|
: { configTargets };
|
|
916
|
-
const compat = await analyzeCompatibility(result.path, analyzeOptions);
|
|
1045
|
+
const compat = await analyzeCompatibility(result.path, locationRoot, analyzeOptions);
|
|
917
1046
|
// Settings conflict detection (when --settings flag is used)
|
|
918
1047
|
if (effectiveSettings === undefined) {
|
|
919
1048
|
compatMap.set(result.path, compat);
|
|
@@ -994,34 +1123,22 @@ function applyVerboseFilter(results, verbose) {
|
|
|
994
1123
|
});
|
|
995
1124
|
}
|
|
996
1125
|
/**
|
|
997
|
-
*
|
|
998
|
-
*
|
|
999
|
-
* `
|
|
1000
|
-
*
|
|
1001
|
-
*
|
|
1002
|
-
*
|
|
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.
|
|
1126
|
+
* Nested carriers inside a file entry that publish their own `path`.
|
|
1127
|
+
*
|
|
1128
|
+
* `linkedFiles[].path` sat absolute in every document while every sibling
|
|
1129
|
+
* `path` and `location` beside it was root-relative — 532 of them in a single
|
|
1130
|
+
* `vat audit --user` run. Naming the carrier here is what re-bases it; a new
|
|
1131
|
+
* carrier that is not named keeps shipping absolute paths.
|
|
1006
1132
|
*/
|
|
1007
|
-
|
|
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
|
-
}
|
|
1133
|
+
const NESTED_PATH_CARRIERS = ['linkedFiles'];
|
|
1017
1134
|
function calculateSummary(results, startTime, compatMap, verbose, root) {
|
|
1018
|
-
const base = buildBaseSummary(results, startTime);
|
|
1019
|
-
const withCompat = applyCompatMap(
|
|
1135
|
+
const { entries, ...base } = buildBaseSummary(results, startTime);
|
|
1136
|
+
const withCompat = applyCompatMap(entries, compatMap);
|
|
1020
1137
|
return {
|
|
1021
1138
|
// Stated once, first, and the only absolute path in the document.
|
|
1022
1139
|
root,
|
|
1023
1140
|
...base,
|
|
1024
|
-
files:
|
|
1141
|
+
files: relativizePathEntries(applyVerboseFilter(withCompat, verbose), root, NESTED_PATH_CARRIERS),
|
|
1025
1142
|
};
|
|
1026
1143
|
}
|
|
1027
1144
|
/**
|
|
@@ -1065,6 +1182,37 @@ function renderIssueEvidence(issue, evidenceSources, logger) {
|
|
|
1065
1182
|
logger.info(formatEvidenceLine(ev));
|
|
1066
1183
|
}
|
|
1067
1184
|
}
|
|
1185
|
+
/**
|
|
1186
|
+
* The name a findings block is headed with — never the empty string.
|
|
1187
|
+
*
|
|
1188
|
+
* `issueLocation` answers "where is this, relative to the scan root", and for a
|
|
1189
|
+
* result that IS the scan root (auditing a plugin directory produces exactly
|
|
1190
|
+
* that) the honest relative path is `''`. Rendered straight into a heading, that
|
|
1191
|
+
* produced ` — 2 warnings:` — the operator was told a nameless something had two
|
|
1192
|
+
* warnings and then, because warnings collapse at default verbosity, shown
|
|
1193
|
+
* neither. Fall back in decreasing order of usefulness to the reader:
|
|
1194
|
+
*
|
|
1195
|
+
* 1. the relative path, whenever there is one (the common case, unchanged);
|
|
1196
|
+
* 2. the resource's own name from metadata already on the result — for a plugin
|
|
1197
|
+
* or skill this is the most recognisable subject there is;
|
|
1198
|
+
* 3. the last path segment, i.e. the scanned directory's own name;
|
|
1199
|
+
* 4. `'.'`, the minimum honest way to say "the root you pointed me at".
|
|
1200
|
+
*
|
|
1201
|
+
* Every step reads data already in hand — no new I/O, and no re-deriving a path
|
|
1202
|
+
* the caller already resolved.
|
|
1203
|
+
*/
|
|
1204
|
+
function findingSubject(result, root) {
|
|
1205
|
+
const location = issueLocation(result.path, root);
|
|
1206
|
+
if (location !== '')
|
|
1207
|
+
return location;
|
|
1208
|
+
const name = result.metadata?.name;
|
|
1209
|
+
if (name !== undefined && name !== '')
|
|
1210
|
+
return name;
|
|
1211
|
+
// `basename` is empty for a filesystem root ('/' or 'C:\'), which is the only
|
|
1212
|
+
// case left that '.' has to cover.
|
|
1213
|
+
const directoryName = basename(result.path);
|
|
1214
|
+
return directoryName === '' ? '.' : directoryName;
|
|
1215
|
+
}
|
|
1068
1216
|
/**
|
|
1069
1217
|
* Render supporting evidence beneath each CAPABILITY_* issue when the
|
|
1070
1218
|
* audit was invoked with --verbose. Evidence comes from the validation
|
|
@@ -1082,13 +1230,13 @@ function renderVerboseEvidence(results, root, logger) {
|
|
|
1082
1230
|
];
|
|
1083
1231
|
if (evidenceSources.length === 0)
|
|
1084
1232
|
continue;
|
|
1085
|
-
logger.info(`\n${
|
|
1233
|
+
logger.info(`\n${findingSubject(result, root)} — supporting evidence:`);
|
|
1086
1234
|
for (const issue of capabilityIssues) {
|
|
1087
1235
|
renderIssueEvidence(issue, evidenceSources, logger);
|
|
1088
1236
|
}
|
|
1089
1237
|
}
|
|
1090
1238
|
}
|
|
1091
|
-
function handleAuditResults(results, summary, logger) {
|
|
1239
|
+
function handleAuditResults(results, summary, logger, verbose) {
|
|
1092
1240
|
const { filesWithErrors: errorCount, filesWithWarnings: warningCount, filesPassed: successCount, } = summary.summary;
|
|
1093
1241
|
// Report settings conflicts (advisory, non-blocking)
|
|
1094
1242
|
const totalSettingsConflicts = (summary.files ?? []).reduce((sum, f) => {
|
|
@@ -1100,10 +1248,12 @@ function handleAuditResults(results, summary, logger) {
|
|
|
1100
1248
|
// Audit is advisory only — always exit 0 for validation results.
|
|
1101
1249
|
// Use vat skills validate for gated validation (exit 1 on errors).
|
|
1102
1250
|
if (errorCount > 0) {
|
|
1103
|
-
|
|
1251
|
+
logger.error(`Audit failed: ${errorCount} file(s) with errors`);
|
|
1252
|
+
logFindingsForStatus(results, 'error', summary.root, logger.error.bind(logger), verbose);
|
|
1104
1253
|
}
|
|
1105
1254
|
else if (warningCount > 0) {
|
|
1106
|
-
|
|
1255
|
+
logger.info(`Audit passed with warnings: ${warningCount} file(s)`);
|
|
1256
|
+
logFindingsForStatus(results, 'warning', summary.root, logger.info.bind(logger), verbose);
|
|
1107
1257
|
}
|
|
1108
1258
|
else {
|
|
1109
1259
|
// `success` means "nothing actionable", NOT "nothing to see" — name the
|
|
@@ -1135,35 +1285,79 @@ function renderAuditFooter(results, logger) {
|
|
|
1135
1285
|
}
|
|
1136
1286
|
renderSkillQualityFooter(logger, hasSkillFindings, emittedCodes);
|
|
1137
1287
|
}
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1288
|
+
/**
|
|
1289
|
+
* The stderr findings report for a set of audit results — the HUMAN channel.
|
|
1290
|
+
*
|
|
1291
|
+
* Deliberately NOT the YAML document. `vat audit` publishes a machine-readable
|
|
1292
|
+
* report on stdout that CI and tooling parse, and that document carries every
|
|
1293
|
+
* finding at every verbosity; this stream is read by a person, and on a 90-skill
|
|
1294
|
+
* corpus it printed 26,480 lines, 3,480 of them occurrences of a single
|
|
1295
|
+
* high-cardinality warning code. The two channels answer different questions, so
|
|
1296
|
+
* only this one collapses.
|
|
1297
|
+
*
|
|
1298
|
+
* Which findings render in full is not decided here — it is
|
|
1299
|
+
* {@link issuesToRenderAtVerbosity}, shared with the other findings-reporting
|
|
1300
|
+
* verbs so `vat audit`, `vat skills validate` and `vat skills build` cannot drift
|
|
1301
|
+
* onto three answers again. Errors always render; warnings and info collapse
|
|
1302
|
+
* unless `verbose`; allow-listed findings never render.
|
|
1303
|
+
*
|
|
1304
|
+
* The severity breakdown on each file's heading is load-bearing, not decoration.
|
|
1305
|
+
* For a warning-only file at default verbosity that heading is the ENTIRE report
|
|
1306
|
+
* for that file, so dropping the counts would turn the collapse into silence —
|
|
1307
|
+
* the failure direction `issue-rendering.ts` exists to prevent. The trailing
|
|
1308
|
+
* line names the collapsed total and both ways to see it, for the same reason.
|
|
1309
|
+
*
|
|
1310
|
+
* That trailing line points at THIS audit's YAML report, deliberately — do not
|
|
1311
|
+
* broaden it back into a claim about "the YAML report" in general. It is only
|
|
1312
|
+
* true of the verbs that emit full `issues:` arrays, and which verbs those are
|
|
1313
|
+
* has changed: `vat skills build` now publishes them too, so the sentence is
|
|
1314
|
+
* shared rather than re-spelled (see `formatCollapsedFindingsHint`, which takes
|
|
1315
|
+
* the report name for exactly this reason). Any verb that publishes counts alone
|
|
1316
|
+
* must still not use it — there, it would send a CI consumer to a document that
|
|
1317
|
+
* cannot answer the question.
|
|
1318
|
+
*
|
|
1319
|
+
* Pure: takes results, returns lines. It reads `issues` and never rewrites it,
|
|
1320
|
+
* because `runAuditAtPath` builds the YAML document from the very same array.
|
|
1321
|
+
*
|
|
1322
|
+
* @internal Exported for unit testing only — not part of the public CLI API.
|
|
1323
|
+
*/
|
|
1324
|
+
export function formatAuditFindingsLines(results, root, verbose) {
|
|
1325
|
+
const lines = [];
|
|
1326
|
+
let collapsed = 0;
|
|
1327
|
+
for (const result of results) {
|
|
1328
|
+
const rendered = issuesToRenderAtVerbosity(result.issues, verbose);
|
|
1329
|
+
// What --verbose WOULD show, minus what this verbosity shows. Derived from
|
|
1330
|
+
// the policy rather than re-stating it, so the count cannot contradict it.
|
|
1331
|
+
collapsed += countCollapsedFindings(result.issues, verbose);
|
|
1332
|
+
// The heading is unconditional and counts the WHOLE set — for a
|
|
1333
|
+
// warning-only file at default verbosity it IS the report. The trailing
|
|
1334
|
+
// colon is what varies: it introduces the blocks below, so a heading with
|
|
1335
|
+
// nothing beneath it does not print one. Same rule, same reason, as
|
|
1336
|
+
// `formatPostBuildIssueReport` in `skills/build.ts` — a colon promising a
|
|
1337
|
+
// list the current verbosity will not print is a promise the renderer
|
|
1338
|
+
// already knows it will break.
|
|
1339
|
+
const heading = `\n${findingSubject(result, root)} — ${formatSeverityBreakdown(countBySeverity(result.issues))}`;
|
|
1340
|
+
lines.push(rendered.length === 0 ? heading : `${heading}:`);
|
|
1341
|
+
for (const issue of rendered) {
|
|
1342
|
+
lines.push(...formatIssueLines(issue, ' '));
|
|
1165
1343
|
}
|
|
1166
1344
|
}
|
|
1345
|
+
const hint = formatCollapsedFindingsHint(collapsed, 'audit');
|
|
1346
|
+
if (hint !== undefined)
|
|
1347
|
+
lines.push(hint);
|
|
1348
|
+
return lines;
|
|
1349
|
+
}
|
|
1350
|
+
/**
|
|
1351
|
+
* Write one status class's findings to stderr beneath the run banner.
|
|
1352
|
+
*
|
|
1353
|
+
* The banner counts FILES and the blocks below count FINDINGS; both are needed,
|
|
1354
|
+
* and `logFn` picks the channel the calling branch already established.
|
|
1355
|
+
*/
|
|
1356
|
+
function logFindingsForStatus(results, status, root, logFn, verbose) {
|
|
1357
|
+
const selected = results.filter((r) => r.status === status);
|
|
1358
|
+
for (const line of formatAuditFindingsLines(selected, root, verbose)) {
|
|
1359
|
+
logFn(line);
|
|
1360
|
+
}
|
|
1167
1361
|
}
|
|
1168
1362
|
/**
|
|
1169
1363
|
* Check whether a path should be excluded during directory scanning.
|
|
@@ -1176,6 +1370,53 @@ function isExcludedPath(isMatch, relativePath, isDirectory) {
|
|
|
1176
1370
|
}
|
|
1177
1371
|
return isMatch(relativePath);
|
|
1178
1372
|
}
|
|
1373
|
+
/**
|
|
1374
|
+
* Does the SKILL.md at `skillMdPath` own its own presence-side crawl, or has an
|
|
1375
|
+
* ancestor crawl already covered it?
|
|
1376
|
+
*
|
|
1377
|
+
* The single place the walk answers that question, for both owner kinds — see
|
|
1378
|
+
* {@link ScanContext.crawledRoot}. `isWithin` rather than a bare
|
|
1379
|
+
* `crawledRoot === null`: the claim is a containment claim and it is spelled
|
|
1380
|
+
* once, in the module that already got the separator right.
|
|
1381
|
+
*/
|
|
1382
|
+
function ownsOwnCrawl(scanCtx, skillMdPath) {
|
|
1383
|
+
return scanCtx.crawledRoot === null || !isWithin(scanCtx.crawledRoot, safePath.resolve(skillMdPath));
|
|
1384
|
+
}
|
|
1385
|
+
/**
|
|
1386
|
+
* Hand the descending context an owner for everything below `owner`, unless one
|
|
1387
|
+
* is already claimed. First owner wins, and `null` claims nothing.
|
|
1388
|
+
*/
|
|
1389
|
+
function claimSubtree(scanCtx, owner) {
|
|
1390
|
+
if (owner === null || scanCtx.crawledRoot !== null)
|
|
1391
|
+
return scanCtx;
|
|
1392
|
+
return { ...scanCtx, crawledRoot: safePath.resolve(owner) };
|
|
1393
|
+
}
|
|
1394
|
+
/**
|
|
1395
|
+
* The context this directory's SUBDIRECTORIES are walked with.
|
|
1396
|
+
*
|
|
1397
|
+
* A `SKILL.md` here means this directory's own crawl reaches every descendant at
|
|
1398
|
+
* any depth, so it — not each nested skill in turn — owns the subtree below.
|
|
1399
|
+
*
|
|
1400
|
+
* Resolved before the entry loop rather than inside it: `SKILL.md` and the
|
|
1401
|
+
* subdirectories it governs are siblings in one `readdir`, and their order is
|
|
1402
|
+
* the filesystem's to choose, so deciding this mid-loop would make the verdict
|
|
1403
|
+
* depend on it.
|
|
1404
|
+
*
|
|
1405
|
+
* Conditional on the crawl actually RUNNING ({@link crawlOwnsSubtree}), not on
|
|
1406
|
+
* the SKILL.md merely existing: a repo-source skill's crawl is skipped
|
|
1407
|
+
* entirely, so claiming its subtree would trade a double count for a missed
|
|
1408
|
+
* finding — and provenance is not monotone down a tree.
|
|
1409
|
+
*/
|
|
1410
|
+
async function contextForDescendants(dirPath, entries, scanCtx) {
|
|
1411
|
+
if (!entries.some((entry) => entry.isFile() && entry.name === 'SKILL.md'))
|
|
1412
|
+
return scanCtx;
|
|
1413
|
+
const skillMd = safePath.join(dirPath, 'SKILL.md');
|
|
1414
|
+
if (!ownsOwnCrawl(scanCtx, skillMd))
|
|
1415
|
+
return scanCtx;
|
|
1416
|
+
if (!await crawlOwnsSubtree(skillMd))
|
|
1417
|
+
return scanCtx;
|
|
1418
|
+
return claimSubtree(scanCtx, dirPath);
|
|
1419
|
+
}
|
|
1179
1420
|
/**
|
|
1180
1421
|
* Handle file entry during directory scan
|
|
1181
1422
|
*/
|
|
@@ -1198,18 +1439,28 @@ async function handleFileEntry(entry, fullPath, options, logger, scanCtx) {
|
|
|
1198
1439
|
logger.debug(` Using config-aware validation for: ${fullPath}`);
|
|
1199
1440
|
// Thread the per-scan tracker into packaging validation so gitignore
|
|
1200
1441
|
// checks in the link-graph walk stay O(1).
|
|
1442
|
+
// `projectSkills`: same project-wide test-input rule as the lane above,
|
|
1443
|
+
// resolved through the same per-config-root memo.
|
|
1444
|
+
const projectSkills = await resolveProjectDeclaredEvalSuites(fullPath);
|
|
1201
1445
|
const sharedCtx = scanCtx.gitTracker === null
|
|
1202
|
-
? { locationRoot }
|
|
1203
|
-
: { gitTracker: scanCtx.gitTracker, locationRoot };
|
|
1446
|
+
? { locationRoot, projectSkills }
|
|
1447
|
+
: { gitTracker: scanCtx.gitTracker, locationRoot, projectSkills };
|
|
1204
1448
|
const packagingResult = await validateSkillForPackaging(fullPath, skillConfig, 'source', sharedCtx);
|
|
1205
|
-
|
|
1449
|
+
const configAware = packagingResultToValidationResult(fullPath, packagingResult, skillConfig.targets, locationRoot);
|
|
1450
|
+
await appendDistributedTreeFindings(configAware, fullPath, locationRoot, ownsOwnCrawl(scanCtx, fullPath));
|
|
1451
|
+
return configAware;
|
|
1206
1452
|
}
|
|
1207
1453
|
// Wild mode: basic validation
|
|
1208
1454
|
const validateOptions = { skillPath: fullPath, locationRoot };
|
|
1209
1455
|
if (options.warnUnreferencedFiles) {
|
|
1210
1456
|
validateOptions.checkUnreferencedFiles = true;
|
|
1211
1457
|
}
|
|
1212
|
-
|
|
1458
|
+
const result = await validateSkill(validateOptions);
|
|
1459
|
+
// Wild mode is where the distributed-tree question is even ASKED; the
|
|
1460
|
+
// classifier answers it. Suppressed under a plugin ancestor, which already
|
|
1461
|
+
// crawled this whole subtree at any depth.
|
|
1462
|
+
await appendDistributedTreeFindings(result, fullPath, locationRoot, ownsOwnCrawl(scanCtx, fullPath));
|
|
1463
|
+
return result;
|
|
1213
1464
|
}
|
|
1214
1465
|
return null;
|
|
1215
1466
|
}
|
|
@@ -1230,15 +1481,64 @@ async function handleDirectoryEntry(fullPath, recursive, options, logger, baseDi
|
|
|
1230
1481
|
appendInventoryParseErrors(result, inv, locationRoot);
|
|
1231
1482
|
results.push(result);
|
|
1232
1483
|
}
|
|
1233
|
-
// Recurse into subdirectories (both plugin/marketplace dirs and regular dirs)
|
|
1484
|
+
// Recurse into subdirectories (both plugin/marketplace dirs and regular dirs).
|
|
1485
|
+
// Below a plugin, `validatePlugin` has already crawled the whole subtree for
|
|
1486
|
+
// agent-instruction files, so every crawl below must stand down or one file
|
|
1487
|
+
// is reported twice. See {@link ScanContext.crawledRoot} — first owner wins,
|
|
1488
|
+
// and it never clears on the way down.
|
|
1489
|
+
const childCtx = claimSubtree(scanCtx, hasClaudePlugin ? fullPath : null);
|
|
1234
1490
|
if (recursive) {
|
|
1235
|
-
const subResults = await scanDirectory(fullPath, recursive, options, logger,
|
|
1491
|
+
const subResults = await scanDirectory(fullPath, recursive, options, logger, childCtx, baseDir, nestedConfigLog);
|
|
1236
1492
|
results.push(...subResults);
|
|
1237
1493
|
}
|
|
1238
1494
|
return results;
|
|
1239
1495
|
}
|
|
1240
|
-
/**
|
|
1241
|
-
|
|
1496
|
+
/**
|
|
1497
|
+
* Compile the governing project's `resources.exclude` for a scan.
|
|
1498
|
+
*
|
|
1499
|
+
* The config is found by walking UP from the scan directory ({@link findProjectRoot}),
|
|
1500
|
+
* not by looking in it. Looking only in the scan directory is how a path argument
|
|
1501
|
+
* silently voided every exclude the project had declared: `vat audit
|
|
1502
|
+
* packages/x/resources/skills/` found no config there, so a package that excludes
|
|
1503
|
+
* its deliberately-broken eval fixtures had them audited as production skills the
|
|
1504
|
+
* moment anyone named a subdirectory. Commit 8a466b0c fixed the same defect for
|
|
1505
|
+
* `vat resources validate|scan` and `vat rag index`; this is the lane it missed.
|
|
1506
|
+
* The rule is the same one that commit established: a path argument says WHICH
|
|
1507
|
+
* tree to audit, and `exclude` applies either way.
|
|
1508
|
+
*
|
|
1509
|
+
* With one deliberate exception, mirroring the gitignore rule in
|
|
1510
|
+
* {@link resolveScanContext}: when the operator points the scan AT an excluded
|
|
1511
|
+
* tree, their explicit intent wins and the excludes are dropped for that run.
|
|
1512
|
+
* Otherwise naming an excluded directory would report `filesScanned: 0,
|
|
1513
|
+
* status: success` — a green run that scanned nothing.
|
|
1514
|
+
*/
|
|
1515
|
+
function resolveProjectExcludes(scanDir, logger) {
|
|
1516
|
+
const projectRoot = findProjectRoot(scanDir);
|
|
1517
|
+
if (projectRoot === null)
|
|
1518
|
+
return null;
|
|
1519
|
+
let patterns;
|
|
1520
|
+
try {
|
|
1521
|
+
patterns = loadConfig(projectRoot)?.resources?.exclude ?? [];
|
|
1522
|
+
}
|
|
1523
|
+
catch (err) {
|
|
1524
|
+
// Audit is a bulk linter over trees it does not own; a broken governing
|
|
1525
|
+
// config must not abort the scan. Say so rather than dropping it silently.
|
|
1526
|
+
logger.debug(`Ignoring unreadable config at ${projectRoot}: ${String(err)}`);
|
|
1527
|
+
return null;
|
|
1528
|
+
}
|
|
1529
|
+
if (patterns.length === 0)
|
|
1530
|
+
return null;
|
|
1531
|
+
// dot:true so excludes like `**/.cache/*` match through dotfile dirs;
|
|
1532
|
+
// without it the exclude silently never fires.
|
|
1533
|
+
const isMatch = picomatch([...patterns], { dot: true });
|
|
1534
|
+
const scanRelToProject = safePath.relative(projectRoot, safePath.resolve(scanDir)).replaceAll('\\', '/');
|
|
1535
|
+
if (scanRelToProject !== '' && isExcludedPath(isMatch, scanRelToProject, true)) {
|
|
1536
|
+
logger.debug(`Scan root ${scanRelToProject} is excluded by the config at ${projectRoot}; ` +
|
|
1537
|
+
'auditing it anyway because it was named explicitly');
|
|
1538
|
+
return null;
|
|
1539
|
+
}
|
|
1540
|
+
return { isMatch, base: projectRoot, label: 'excluded by config' };
|
|
1541
|
+
}
|
|
1242
1542
|
/**
|
|
1243
1543
|
* Cache of (projectRoot → registry for the *inventory* link walk).
|
|
1244
1544
|
*
|
|
@@ -1264,10 +1564,27 @@ async function getOrCreateInventoryRegistry(projectRoot) {
|
|
|
1264
1564
|
/**
|
|
1265
1565
|
* `extractClaudePluginInventory` for a plugin directory, handing it the shared
|
|
1266
1566
|
* inventory registry so each skill it walks does not re-parse the corpus.
|
|
1567
|
+
*
|
|
1568
|
+
* A PROVIDER, not an awaited registry, and only when there is a project root — the same
|
|
1569
|
+
* two rules `vat inventory`'s `linkRegistryProviderFor` follows, for the same two reasons:
|
|
1570
|
+
*
|
|
1571
|
+
* 1. **Unresolved.** `extractClaudePluginInventory` promises "never throws — all failures
|
|
1572
|
+
* surface via parseErrors[]", and that promise holds only while the crawl runs inside
|
|
1573
|
+
* `walkLinkedFiles`'s try/catch. Awaiting it here moved the crawl outside that catch,
|
|
1574
|
+
* so one unreadable markdown file anywhere under the root aborted the WHOLE audit with
|
|
1575
|
+
* `status: error` and exit code 2 instead of degrading three link walks. The extractor
|
|
1576
|
+
* memoizes the provider and caches even a rejection, so the failed crawl is still
|
|
1577
|
+
* attempted once, not once per skill.
|
|
1578
|
+
* 2. **Only a project root.** Reuse is gated on exact `registry.baseDir === projectRoot`
|
|
1579
|
+
* equality against the root `walkLinkedFiles` derives per skill
|
|
1580
|
+
* (`findProjectRoot(dirname(skillMd)) ?? dirname(skillMd)`). With no project root that
|
|
1581
|
+
* per-skill fallback is each skill's OWN directory, so a registry rooted at the plugin
|
|
1582
|
+
* matches nothing: it would be crawled, compared, discarded, and re-crawled per skill.
|
|
1583
|
+
* No root, no provider.
|
|
1267
1584
|
*/
|
|
1268
1585
|
async function pluginInventoryAt(dir) {
|
|
1269
|
-
const
|
|
1270
|
-
return extractClaudePluginInventory(dir,
|
|
1586
|
+
const projectRoot = findProjectRoot(dir);
|
|
1587
|
+
return extractClaudePluginInventory(dir, projectRoot === null ? undefined : () => getOrCreateInventoryRegistry(projectRoot));
|
|
1271
1588
|
}
|
|
1272
1589
|
/**
|
|
1273
1590
|
* Reset all module-level audit caches. Must be called at the start of every
|
|
@@ -1276,23 +1593,13 @@ async function pluginInventoryAt(dir) {
|
|
|
1276
1593
|
* governing configs, or skill-discovery maps from a prior run.
|
|
1277
1594
|
*/
|
|
1278
1595
|
export function resetAuditCaches() {
|
|
1279
|
-
|
|
1596
|
+
resetGitTrackerCache();
|
|
1280
1597
|
resetPackagingRegistryCache();
|
|
1281
1598
|
inventoryRegistryCache.clear();
|
|
1282
1599
|
resetProjectRootCaches();
|
|
1283
1600
|
resetLoadedConfigCache();
|
|
1284
1601
|
resetSkillDiscoveryCache();
|
|
1285
1602
|
}
|
|
1286
|
-
async function getOrCreateGitTracker(gitRoot) {
|
|
1287
|
-
const cached = gitTrackerCache.get(gitRoot);
|
|
1288
|
-
if (cached !== undefined) {
|
|
1289
|
-
return cached;
|
|
1290
|
-
}
|
|
1291
|
-
const tracker = new GitTracker(gitRoot);
|
|
1292
|
-
await tracker.initialize();
|
|
1293
|
-
gitTrackerCache.set(gitRoot, tracker);
|
|
1294
|
-
return tracker;
|
|
1295
|
-
}
|
|
1296
1603
|
/**
|
|
1297
1604
|
* Resolve the effective git root and build a per-repo {@link GitTracker} for a
|
|
1298
1605
|
* scan. On the first call for a scan (no tracker yet), detects the git root
|
|
@@ -1303,18 +1610,19 @@ async function getOrCreateGitTracker(gitRoot) {
|
|
|
1303
1610
|
* `dist/`), returns `{ gitRoot: null, gitTracker: null }` to disable gitignore
|
|
1304
1611
|
* filtering — the user's explicit intent takes precedence over .gitignore.
|
|
1305
1612
|
*/
|
|
1306
|
-
async function resolveScanContext(dirPath, locationRoot) {
|
|
1613
|
+
async function resolveScanContext(dirPath, locationRoot, logger) {
|
|
1614
|
+
const projectExcludes = resolveProjectExcludes(dirPath, logger);
|
|
1307
1615
|
const detectedRoot = gitFindRoot(dirPath);
|
|
1308
1616
|
if (detectedRoot === null) {
|
|
1309
|
-
return { gitRoot: null, gitTracker: null, locationRoot };
|
|
1617
|
+
return { gitRoot: null, gitTracker: null, locationRoot, projectExcludes, crawledRoot: null };
|
|
1310
1618
|
}
|
|
1311
1619
|
const tracker = await getOrCreateGitTracker(detectedRoot);
|
|
1312
1620
|
// If the scan root itself is gitignored (e.g. user targeted `dist/`), disable
|
|
1313
1621
|
// gitignore filtering so the user's explicit intent wins.
|
|
1314
1622
|
if (tracker.isIgnoredByActiveSet(dirPath)) {
|
|
1315
|
-
return { gitRoot: null, gitTracker: null, locationRoot };
|
|
1623
|
+
return { gitRoot: null, gitTracker: null, locationRoot, projectExcludes, crawledRoot: null };
|
|
1316
1624
|
}
|
|
1317
|
-
return { gitRoot: detectedRoot, gitTracker: tracker, locationRoot };
|
|
1625
|
+
return { gitRoot: detectedRoot, gitTracker: tracker, locationRoot, projectExcludes, crawledRoot: null };
|
|
1318
1626
|
}
|
|
1319
1627
|
/**
|
|
1320
1628
|
* Build the gitignore exclusion map for a set of entry paths.
|
|
@@ -1335,7 +1643,7 @@ function buildGitIgnoreMap(entryPaths, gitTracker, includeArtifacts) {
|
|
|
1335
1643
|
* Check whether a directory entry should be skipped during scanning.
|
|
1336
1644
|
* Returns a reason string for debug logging, or null if the entry should be kept.
|
|
1337
1645
|
*/
|
|
1338
|
-
function getSkipReason(entry, fullPath,
|
|
1646
|
+
function getSkipReason(entry, fullPath, gitIgnoredMap, excludeMatchers) {
|
|
1339
1647
|
// Always skip .git directory (not reported by git check-ignore)
|
|
1340
1648
|
if (entry.isDirectory() && entry.name === '.git') {
|
|
1341
1649
|
return '.git';
|
|
@@ -1344,11 +1652,11 @@ function getSkipReason(entry, fullPath, resolvedBaseDir, gitIgnoredMap, isMatch)
|
|
|
1344
1652
|
if (gitIgnoredMap !== null && gitIgnoredMap.get(fullPath) === true) {
|
|
1345
1653
|
return `gitignored: ${entry.name}`;
|
|
1346
1654
|
}
|
|
1347
|
-
//
|
|
1348
|
-
|
|
1349
|
-
const relativePath = safePath.relative(
|
|
1350
|
-
if (isExcludedPath(isMatch, relativePath, entry.isDirectory())) {
|
|
1351
|
-
return
|
|
1655
|
+
// Each matcher answers against ITS OWN base — see {@link ExcludeMatcher}.
|
|
1656
|
+
for (const matcher of excludeMatchers) {
|
|
1657
|
+
const relativePath = safePath.relative(matcher.base, fullPath).replaceAll('\\', '/');
|
|
1658
|
+
if (isExcludedPath(matcher.isMatch, relativePath, entry.isDirectory())) {
|
|
1659
|
+
return `${matcher.label}: ${relativePath}`;
|
|
1352
1660
|
}
|
|
1353
1661
|
}
|
|
1354
1662
|
return null;
|
|
@@ -1381,14 +1689,27 @@ async function scanDirectory(dirPath, recursive, options, logger, scanCtx, baseD
|
|
|
1381
1689
|
// Compile picomatch for user-supplied --exclude patterns.
|
|
1382
1690
|
// dot:true so excludes like `**/private/*` match through dotfile dirs
|
|
1383
1691
|
// (`.claude/.../private/x`); without it the exclude silently never fires.
|
|
1384
|
-
|
|
1692
|
+
// These are relative to the scan base; the config's excludes carry their own
|
|
1693
|
+
// (project-root) base on the matcher — see {@link ExcludeMatcher}.
|
|
1694
|
+
const excludeMatchers = [];
|
|
1695
|
+
if (userExcludes.length > 0) {
|
|
1696
|
+
excludeMatchers.push({
|
|
1697
|
+
isMatch: picomatch(userExcludes, { dot: true }),
|
|
1698
|
+
base: resolvedBaseDir,
|
|
1699
|
+
label: 'excluded',
|
|
1700
|
+
});
|
|
1701
|
+
}
|
|
1702
|
+
if (resolvedScanCtx.projectExcludes !== null) {
|
|
1703
|
+
excludeMatchers.push(resolvedScanCtx.projectExcludes);
|
|
1704
|
+
}
|
|
1385
1705
|
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
|
1706
|
+
const descendCtx = await contextForDescendants(dirPath, entries, resolvedScanCtx);
|
|
1386
1707
|
// Build full paths and batch-check gitignore status
|
|
1387
1708
|
const entryPaths = entries.map(e => safePath.join(dirPath, e.name));
|
|
1388
1709
|
const gitIgnoredMap = buildGitIgnoreMap(entryPaths, resolvedScanCtx.gitTracker, options.includeArtifacts ?? false);
|
|
1389
1710
|
for (const entry of entries) {
|
|
1390
1711
|
const fullPath = safePath.join(dirPath, entry.name);
|
|
1391
|
-
const skipReason = getSkipReason(entry, fullPath,
|
|
1712
|
+
const skipReason = getSkipReason(entry, fullPath, gitIgnoredMap, excludeMatchers);
|
|
1392
1713
|
if (skipReason !== null) {
|
|
1393
1714
|
logger.debug(`Excluding path: ${skipReason}`);
|
|
1394
1715
|
continue;
|
|
@@ -1400,7 +1721,9 @@ async function scanDirectory(dirPath, recursive, options, logger, scanCtx, baseD
|
|
|
1400
1721
|
}
|
|
1401
1722
|
}
|
|
1402
1723
|
else if (entry.isDirectory()) {
|
|
1403
|
-
|
|
1724
|
+
// `descendCtx`, not `resolvedScanCtx`: this level's own skill (if any)
|
|
1725
|
+
// owns everything below, including this subdirectory.
|
|
1726
|
+
const dirResults = await handleDirectoryEntry(fullPath, recursive, options, logger, resolvedBaseDir, descendCtx, resolvedNestedLog);
|
|
1404
1727
|
results.push(...dirResults);
|
|
1405
1728
|
}
|
|
1406
1729
|
}
|
|
@@ -1441,17 +1764,45 @@ function countFilesByStatus(results) {
|
|
|
1441
1764
|
};
|
|
1442
1765
|
}
|
|
1443
1766
|
/**
|
|
1444
|
-
* Build base summary structure (used by both flat and hierarchical)
|
|
1767
|
+
* Build base summary structure (used by both flat and hierarchical), together
|
|
1768
|
+
* with the entries the document will publish.
|
|
1445
1769
|
*
|
|
1446
1770
|
* `issueCounts` is named exactly as it is on every individual file entry, and
|
|
1447
1771
|
* means the same thing at both levels: findings, by severity. `summary` only ever
|
|
1448
1772
|
* counts files.
|
|
1773
|
+
*
|
|
1774
|
+
* The header total and every per-file total come from ONE traversal of the
|
|
1775
|
+
* final issue set, and the entries carrying those per-file totals are returned
|
|
1776
|
+
* from the same pass. Before that, the header was derived from the issue
|
|
1777
|
+
* records while each file's `issueCounts` was whatever its producer had
|
|
1778
|
+
* published — and a producer that appends findings after publishing (or never
|
|
1779
|
+
* sets the counts at all: `validatePlugin` writes `status` and `summary` but
|
|
1780
|
+
* not `issueCounts`) left the two disagreeing inside one document. A real
|
|
1781
|
+
* `vat audit --user` run said 55/422/504 in the header over per-file counts
|
|
1782
|
+
* summing to 55/360/405, with 91 of 614 entries declaring `{0,0,0}` above the
|
|
1783
|
+
* findings they listed. Re-deriving here means the header and the sum are the
|
|
1784
|
+
* same arithmetic on the same array, so they cannot drift again no matter what
|
|
1785
|
+
* a producer forgets.
|
|
1786
|
+
*
|
|
1787
|
+
* `linkedFiles[].issues` are deliberately NOT added: every linked finding is
|
|
1788
|
+
* also present in its owner's `issues`, so the nested list is a VIEW of records
|
|
1789
|
+
* already counted, not extra records. `audit-report-coherence.integration.test.ts`
|
|
1790
|
+
* pins that subset relation, because counting once is only correct while it holds.
|
|
1449
1791
|
*/
|
|
1450
1792
|
function buildBaseSummary(results, startTime) {
|
|
1793
|
+
const issueCounts = { errors: 0, warnings: 0, info: 0 };
|
|
1794
|
+
const entries = results.map((result) => {
|
|
1795
|
+
const counts = countBySeverity(result.issues);
|
|
1796
|
+
issueCounts.errors += counts.errors;
|
|
1797
|
+
issueCounts.warnings += counts.warnings;
|
|
1798
|
+
issueCounts.info += counts.info;
|
|
1799
|
+
return { ...result, issueCounts: counts };
|
|
1800
|
+
});
|
|
1451
1801
|
return {
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1802
|
+
entries,
|
|
1803
|
+
status: calculateOverallStatus(entries),
|
|
1804
|
+
summary: countFilesByStatus(entries),
|
|
1805
|
+
issueCounts,
|
|
1455
1806
|
duration: `${Date.now() - startTime}ms`,
|
|
1456
1807
|
};
|
|
1457
1808
|
}
|
|
@@ -1459,8 +1810,8 @@ function buildBaseSummary(results, startTime) {
|
|
|
1459
1810
|
* Calculate summary for hierarchical output
|
|
1460
1811
|
*/
|
|
1461
1812
|
function calculateHierarchicalSummary(results, hierarchical, startTime, compatMap, verbose, root) {
|
|
1462
|
-
const base = buildBaseSummary(results, startTime);
|
|
1463
|
-
const withCompat = applyCompatMap(
|
|
1813
|
+
const { entries, ...base } = buildBaseSummary(results, startTime);
|
|
1814
|
+
const withCompat = applyCompatMap(entries, compatMap);
|
|
1464
1815
|
return {
|
|
1465
1816
|
root,
|
|
1466
1817
|
...base,
|
|
@@ -1471,7 +1822,7 @@ function calculateHierarchicalSummary(results, hierarchical, startTime, compatMa
|
|
|
1471
1822
|
standalonePlugins: hierarchical.standalonePlugins.length,
|
|
1472
1823
|
standaloneSkills: hierarchical.standaloneSkills.length,
|
|
1473
1824
|
},
|
|
1474
|
-
files:
|
|
1825
|
+
files: relativizePathEntries(applyVerboseFilter(withCompat, verbose), root, NESTED_PATH_CARRIERS),
|
|
1475
1826
|
hierarchical,
|
|
1476
1827
|
};
|
|
1477
1828
|
}
|