@vibe-agent-toolkit/cli 0.1.41-rc.5 → 0.1.41-rc.7

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