@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.
Files changed (185) hide show
  1. package/dist/bin/vat +24 -1
  2. package/dist/bin/vat.js +24 -1
  3. package/dist/bin/vat.js.map +1 -1
  4. package/dist/bin.js +23 -2
  5. package/dist/bin.js.map +1 -1
  6. package/dist/commands/agent/list.d.ts +11 -0
  7. package/dist/commands/agent/list.d.ts.map +1 -1
  8. package/dist/commands/agent/list.js +30 -11
  9. package/dist/commands/agent/list.js.map +1 -1
  10. package/dist/commands/audit/distributed-tree.d.ts +172 -0
  11. package/dist/commands/audit/distributed-tree.d.ts.map +1 -0
  12. package/dist/commands/audit/distributed-tree.js +319 -0
  13. package/dist/commands/audit/distributed-tree.js.map +1 -0
  14. package/dist/commands/audit.d.ts +69 -1
  15. package/dist/commands/audit.d.ts.map +1 -1
  16. package/dist/commands/audit.js +525 -174
  17. package/dist/commands/audit.js.map +1 -1
  18. package/dist/commands/build.d.ts +14 -0
  19. package/dist/commands/build.d.ts.map +1 -1
  20. package/dist/commands/build.js +103 -26
  21. package/dist/commands/build.js.map +1 -1
  22. package/dist/commands/claude/marketplace/git-publish.d.ts.map +1 -1
  23. package/dist/commands/claude/marketplace/git-publish.js +21 -3
  24. package/dist/commands/claude/marketplace/git-publish.js.map +1 -1
  25. package/dist/commands/claude/marketplace/validate.d.ts +125 -0
  26. package/dist/commands/claude/marketplace/validate.d.ts.map +1 -1
  27. package/dist/commands/claude/marketplace/validate.js +251 -44
  28. package/dist/commands/claude/marketplace/validate.js.map +1 -1
  29. package/dist/commands/claude/org/stubs.d.ts +9 -1
  30. package/dist/commands/claude/org/stubs.d.ts.map +1 -1
  31. package/dist/commands/claude/org/stubs.js +11 -3
  32. package/dist/commands/claude/org/stubs.js.map +1 -1
  33. package/dist/commands/claude/plugin/build.d.ts +34 -4
  34. package/dist/commands/claude/plugin/build.d.ts.map +1 -1
  35. package/dist/commands/claude/plugin/build.js +187 -16
  36. package/dist/commands/claude/plugin/build.js.map +1 -1
  37. package/dist/commands/claude/plugin/plugin-files.d.ts.map +1 -1
  38. package/dist/commands/claude/plugin/plugin-files.js +6 -2
  39. package/dist/commands/claude/plugin/plugin-files.js.map +1 -1
  40. package/dist/commands/claude/plugin/plugin-json-merge.d.ts.map +1 -1
  41. package/dist/commands/claude/plugin/plugin-json-merge.js +15 -0
  42. package/dist/commands/claude/plugin/plugin-json-merge.js.map +1 -1
  43. package/dist/commands/claude/plugin/tree-copy.d.ts +40 -0
  44. package/dist/commands/claude/plugin/tree-copy.d.ts.map +1 -1
  45. package/dist/commands/claude/plugin/tree-copy.js +100 -4
  46. package/dist/commands/claude/plugin/tree-copy.js.map +1 -1
  47. package/dist/commands/corpus/report.d.ts +22 -4
  48. package/dist/commands/corpus/report.d.ts.map +1 -1
  49. package/dist/commands/corpus/report.js +1 -0
  50. package/dist/commands/corpus/report.js.map +1 -1
  51. package/dist/commands/corpus/runner.d.ts +16 -1
  52. package/dist/commands/corpus/runner.d.ts.map +1 -1
  53. package/dist/commands/corpus/runner.js +40 -22
  54. package/dist/commands/corpus/runner.js.map +1 -1
  55. package/dist/commands/doctor.js +1 -1
  56. package/dist/commands/doctor.js.map +1 -1
  57. package/dist/commands/inventory.d.ts +2 -0
  58. package/dist/commands/inventory.d.ts.map +1 -1
  59. package/dist/commands/inventory.js +45 -4
  60. package/dist/commands/inventory.js.map +1 -1
  61. package/dist/commands/phase-utils.d.ts +183 -14
  62. package/dist/commands/phase-utils.d.ts.map +1 -1
  63. package/dist/commands/phase-utils.js +294 -29
  64. package/dist/commands/phase-utils.js.map +1 -1
  65. package/dist/commands/positional-args.d.ts +36 -0
  66. package/dist/commands/positional-args.d.ts.map +1 -0
  67. package/dist/commands/positional-args.js +52 -0
  68. package/dist/commands/positional-args.js.map +1 -0
  69. package/dist/commands/rag/query-command.d.ts +40 -0
  70. package/dist/commands/rag/query-command.d.ts.map +1 -1
  71. package/dist/commands/rag/query-command.js +47 -20
  72. package/dist/commands/rag/query-command.js.map +1 -1
  73. package/dist/commands/resources/index.d.ts.map +1 -1
  74. package/dist/commands/resources/index.js +27 -9
  75. package/dist/commands/resources/index.js.map +1 -1
  76. package/dist/commands/resources/scan.d.ts +37 -0
  77. package/dist/commands/resources/scan.d.ts.map +1 -1
  78. package/dist/commands/resources/scan.js +47 -32
  79. package/dist/commands/resources/scan.js.map +1 -1
  80. package/dist/commands/resources/validate.d.ts +136 -1
  81. package/dist/commands/resources/validate.d.ts.map +1 -1
  82. package/dist/commands/resources/validate.js +105 -66
  83. package/dist/commands/resources/validate.js.map +1 -1
  84. package/dist/commands/skill/review-checklist.d.ts.map +1 -1
  85. package/dist/commands/skill/review-checklist.js +5 -0
  86. package/dist/commands/skill/review-checklist.js.map +1 -1
  87. package/dist/commands/skill/review.d.ts.map +1 -1
  88. package/dist/commands/skill/review.js +7 -3
  89. package/dist/commands/skill/review.js.map +1 -1
  90. package/dist/commands/skill/test/run.d.ts +1 -1
  91. package/dist/commands/skill/test/run.d.ts.map +1 -1
  92. package/dist/commands/skill/test/run.js +8 -6
  93. package/dist/commands/skill/test/run.js.map +1 -1
  94. package/dist/commands/skills/build.d.ts +349 -11
  95. package/dist/commands/skills/build.d.ts.map +1 -1
  96. package/dist/commands/skills/build.js +901 -97
  97. package/dist/commands/skills/build.js.map +1 -1
  98. package/dist/commands/skills/command-helpers.d.ts +6 -10
  99. package/dist/commands/skills/command-helpers.d.ts.map +1 -1
  100. package/dist/commands/skills/command-helpers.js +6 -22
  101. package/dist/commands/skills/command-helpers.js.map +1 -1
  102. package/dist/commands/skills/list.d.ts +18 -0
  103. package/dist/commands/skills/list.d.ts.map +1 -1
  104. package/dist/commands/skills/list.js +41 -15
  105. package/dist/commands/skills/list.js.map +1 -1
  106. package/dist/commands/skills/package.d.ts +15 -0
  107. package/dist/commands/skills/package.d.ts.map +1 -1
  108. package/dist/commands/skills/package.js +30 -20
  109. package/dist/commands/skills/package.js.map +1 -1
  110. package/dist/commands/skills/scope-guard.d.ts +64 -0
  111. package/dist/commands/skills/scope-guard.d.ts.map +1 -0
  112. package/dist/commands/skills/scope-guard.js +86 -0
  113. package/dist/commands/skills/scope-guard.js.map +1 -0
  114. package/dist/commands/skills/validate-command.d.ts.map +1 -1
  115. package/dist/commands/skills/validate-command.js +41 -7
  116. package/dist/commands/skills/validate-command.js.map +1 -1
  117. package/dist/commands/skills/validate.d.ts +44 -3
  118. package/dist/commands/skills/validate.d.ts.map +1 -1
  119. package/dist/commands/skills/validate.js +217 -53
  120. package/dist/commands/skills/validate.js.map +1 -1
  121. package/dist/commands/validate.d.ts +18 -0
  122. package/dist/commands/validate.d.ts.map +1 -1
  123. package/dist/commands/validate.js +89 -68
  124. package/dist/commands/validate.js.map +1 -1
  125. package/dist/commands/verify.d.ts +113 -6
  126. package/dist/commands/verify.d.ts.map +1 -1
  127. package/dist/commands/verify.js +440 -123
  128. package/dist/commands/verify.js.map +1 -1
  129. package/dist/index.d.ts +1 -1
  130. package/dist/index.d.ts.map +1 -1
  131. package/dist/index.js +7 -1
  132. package/dist/index.js.map +1 -1
  133. package/dist/skill-resolution/index.d.ts +1 -1
  134. package/dist/skill-resolution/index.d.ts.map +1 -1
  135. package/dist/skill-resolution/index.js +1 -1
  136. package/dist/skill-resolution/index.js.map +1 -1
  137. package/dist/skill-resolution/packaging-config.d.ts +15 -2
  138. package/dist/skill-resolution/packaging-config.d.ts.map +1 -1
  139. package/dist/skill-resolution/packaging-config.js +39 -2
  140. package/dist/skill-resolution/packaging-config.js.map +1 -1
  141. package/dist/utils/command-error.d.ts +34 -1
  142. package/dist/utils/command-error.d.ts.map +1 -1
  143. package/dist/utils/command-error.js +32 -1
  144. package/dist/utils/command-error.js.map +1 -1
  145. package/dist/utils/help-loader.d.ts +6 -2
  146. package/dist/utils/help-loader.d.ts.map +1 -1
  147. package/dist/utils/help-loader.js +9 -16
  148. package/dist/utils/help-loader.js.map +1 -1
  149. package/dist/utils/issue-rendering.d.ts +130 -0
  150. package/dist/utils/issue-rendering.d.ts.map +1 -1
  151. package/dist/utils/issue-rendering.js +147 -0
  152. package/dist/utils/issue-rendering.js.map +1 -1
  153. package/dist/utils/issue-severity.d.ts +51 -0
  154. package/dist/utils/issue-severity.d.ts.map +1 -0
  155. package/dist/utils/issue-severity.js +63 -0
  156. package/dist/utils/issue-severity.js.map +1 -0
  157. package/dist/utils/output.d.ts +54 -5
  158. package/dist/utils/output.d.ts.map +1 -1
  159. package/dist/utils/output.js +111 -17
  160. package/dist/utils/output.js.map +1 -1
  161. package/dist/utils/relativize-paths.d.ts +45 -0
  162. package/dist/utils/relativize-paths.d.ts.map +1 -0
  163. package/dist/utils/relativize-paths.js +67 -0
  164. package/dist/utils/relativize-paths.js.map +1 -0
  165. package/dist/utils/resource-loader.d.ts +21 -2
  166. package/dist/utils/resource-loader.d.ts.map +1 -1
  167. package/dist/utils/resource-loader.js +85 -11
  168. package/dist/utils/resource-loader.js.map +1 -1
  169. package/dist/utils/skill-packaging-config.d.ts +22 -1
  170. package/dist/utils/skill-packaging-config.d.ts.map +1 -1
  171. package/dist/utils/skill-packaging-config.js +26 -0
  172. package/dist/utils/skill-packaging-config.js.map +1 -1
  173. package/dist/utils/verdict-helpers.d.ts +4 -3
  174. package/dist/utils/verdict-helpers.d.ts.map +1 -1
  175. package/dist/utils/verdict-helpers.js +6 -13
  176. package/dist/utils/verdict-helpers.js.map +1 -1
  177. package/dist/version.d.ts +22 -2
  178. package/dist/version.d.ts.map +1 -1
  179. package/dist/version.js +31 -2
  180. package/dist/version.js.map +1 -1
  181. package/docs/index.md +12 -0
  182. package/docs/rag.md +9 -4
  183. package/docs/resources.md +30 -14
  184. package/docs/skills.md +25 -7
  185. package/package.json +12 -12
@@ -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, GitTracker, isAbsolutePath, isGitUrl, issueLocation, parseGitUrl, resetProjectRootCaches, safePath, } from '@vibe-agent-toolkit/utils';
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 { formatIssueAnchor } from '../utils/issue-anchor.js';
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
- return packagingResultToValidationResult(skillPath, packagingResult, skillConfig.targets, locationRoot);
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
- return validateSkill(validateOptions);
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
- .option('--verbose', 'Show all scanned resources, including those without issues')
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
- When a vibe-agent-toolkit.config.yaml is found at the git root, audit
310
- uses the project's build settings (linkFollowDepth, files,
311
- excludeReferencesFromBundle) to validate skills. This prevents false
312
- warnings for links that the build pipeline resolves.
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 is respected for display grouping.
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
- if (pluginsDirExists) {
358
- logger.debug(`Auditing user-level plugins at: ${pluginsDir}`);
359
- results.push(...await getValidationResults(pluginsDir, recursive, options, logger, scanRoot));
360
- }
361
- if (skillsDirExists) {
362
- logger.debug(`Auditing user-level skills at: ${skillsDir}`);
363
- results.push(...await getValidationResults(skillsDir, recursive, options, logger, scanRoot));
364
- }
365
- if (marketplacesDirExists) {
366
- logger.debug(`Auditing user-level marketplaces at: ${marketplacesDir}`);
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
- * For a direct SKILL.md path, the project root is two levels up (skill dir → skills/ → project/).
429
- * For a directory or undefined path, use the directory itself (or cwd).
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
- if (targetPath === undefined) {
433
- return process.cwd();
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 filtering to validation results.
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`. Codes configured as
461
- * `severity: 'ignore'` are stripped from the result issues before rendering.
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
- * The severity config is per-skill, keyed by skill name in
464
- * `config.skills.config[skillName].validation.severity`.
465
- * Defaults config (`config.skills.defaults.validation.severity`) is also
466
- * checked as a fallback.
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
- if (!SKILL_RESULT_TYPES.has(result.type)) {
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 filteredIssues = result.issues.filter(issue => effectiveSeverity[issue.code] !== 'ignore');
492
- if (filteredIssues.length === result.issues.length) {
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, filteredIssues);
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
- return validateSingleSkill(surface.path, options, logger, locationRoot);
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
- // Merge resources.exclude from config with --exclude CLI flag patterns.
817
- // Both use the same picomatch semantics. Do NOT mutate options.
818
- const config = loadConfig(deriveConfigRoot(scanPath));
819
- const configExcludes = config?.resources?.exclude ?? [];
820
- const mergedOptions = configExcludes.length > 0
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
- results.push(await validateSingleSkill(skill.files.skillMd, options, logger, locationRoot));
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
- * Re-base each emitted file entry's `path` onto the run root.
998
- *
999
- * `ValidationResult.path` stays ABSOLUTE inside the pipeline on purpose: it is
1000
- * the identity every internal map keys on (compat results, visited-path
1001
- * de-duplication, registry caches), and unlike `location` it is trivially
1002
- * recoverable — an absolute path plus the known root yields the relative form.
1003
- * `location` is the opposite: it cannot be re-based here because a relative
1004
- * string whose base was never stated is not recoverable, which is exactly why
1005
- * the base is threaded down to the producers instead.
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
- function relativizeFileEntries(entries, root) {
1008
- return entries.map((entry) => {
1009
- const relative = issueLocation(entry.path, root);
1010
- // Pointing audit AT a single resource makes that resource the root, which
1011
- // relativizes to the empty string. Spell it `.` — the POSIX name for "the
1012
- // root itself" — so no consumer has to special-case an empty path, and
1013
- // `join(root, path)` still resolves.
1014
- return { ...entry, path: relative === '' ? '.' : relative };
1015
- });
1016
- }
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(results, compatMap);
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: relativizeFileEntries(applyVerboseFilter(withCompat, verbose), root),
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${issueLocation(result.path, root)} — supporting evidence:`);
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
- logErrors(results, errorCount, summary.root, logger);
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
- logWarnings(results, warningCount, summary.root, logger);
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
- function logErrors(results, errorCount, root, logger) {
1139
- logger.error(`Audit failed: ${errorCount} file(s) with errors`);
1140
- const errorResults = results.filter((r) => r.status === 'error');
1141
- for (const result of errorResults) {
1142
- logger.error(`\n${issueLocation(result.path, root)}:`);
1143
- const errorIssues = result.issues.filter((i) => i.severity === 'error');
1144
- logIssues(errorIssues, logger.error.bind(logger));
1145
- }
1146
- }
1147
- function logWarnings(results, warningCount, root, logger) {
1148
- logger.info(`Audit passed with warnings: ${warningCount} file(s)`);
1149
- const warningResults = results.filter((r) => r.status === 'warning');
1150
- for (const result of warningResults) {
1151
- logger.info(`\n${issueLocation(result.path, root)}:`);
1152
- const warningIssues = result.issues.filter((i) => i.severity === 'warning');
1153
- logIssues(warningIssues, logger.info.bind(logger));
1154
- }
1155
- }
1156
- function logIssues(issues, logFn) {
1157
- for (const issue of issues) {
1158
- logFn(` [${issue.code}] ${issue.message}`);
1159
- const anchor = formatIssueAnchor(issue);
1160
- if (anchor !== undefined) {
1161
- logFn(` at: ${anchor}`);
1162
- }
1163
- if (issue.fix) {
1164
- logFn(` fix: ${issue.fix}`);
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
- return packagingResultToValidationResult(fullPath, packagingResult, skillConfig.targets, locationRoot);
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
- return validateSkill(validateOptions);
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, scanCtx, baseDir, nestedConfigLog);
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
- /** Cache of (gitRoot → initialized GitTracker) to avoid re-spawning ls-files. */
1241
- const gitTrackerCache = new Map();
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 root = findProjectRoot(dir) ?? dir;
1270
- return extractClaudePluginInventory(dir, await getOrCreateInventoryRegistry(root));
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
- gitTrackerCache.clear();
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, resolvedBaseDir, gitIgnoredMap, isMatch) {
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
- // Check user-supplied --exclude patterns
1348
- if (isMatch !== null) {
1349
- const relativePath = safePath.relative(resolvedBaseDir, fullPath).replaceAll('\\', '/');
1350
- if (isExcludedPath(isMatch, relativePath, entry.isDirectory())) {
1351
- return `excluded: ${relativePath}`;
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
- const isMatch = userExcludes.length > 0 ? picomatch(userExcludes, { dot: true }) : null;
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, resolvedBaseDir, gitIgnoredMap, isMatch);
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
- const dirResults = await handleDirectoryEntry(fullPath, recursive, options, logger, resolvedBaseDir, resolvedScanCtx, resolvedNestedLog);
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
- status: calculateOverallStatus(results),
1453
- summary: countFilesByStatus(results),
1454
- issueCounts: countBySeverity(results.flatMap((r) => r.issues)),
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(results, compatMap);
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: relativizeFileEntries(applyVerboseFilter(withCompat, verbose), root),
1825
+ files: relativizePathEntries(applyVerboseFilter(withCompat, verbose), root, NESTED_PATH_CARRIERS),
1475
1826
  hierarchical,
1476
1827
  };
1477
1828
  }