@vibe-agent-toolkit/cli 0.1.41-rc.6 → 0.1.41-rc.8

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