@vibe-agent-toolkit/cli 0.1.41-rc.7 → 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 (149) hide show
  1. package/dist/bin/vat +24 -1
  2. package/dist/bin/vat.js +24 -1
  3. package/dist/bin/vat.js.map +1 -1
  4. package/dist/bin.js +12 -1
  5. package/dist/bin.js.map +1 -1
  6. package/dist/commands/agent/list.d.ts +11 -0
  7. package/dist/commands/agent/list.d.ts.map +1 -1
  8. package/dist/commands/agent/list.js +30 -11
  9. package/dist/commands/agent/list.js.map +1 -1
  10. package/dist/commands/audit.d.ts +34 -1
  11. package/dist/commands/audit.d.ts.map +1 -1
  12. package/dist/commands/audit.js +220 -88
  13. package/dist/commands/audit.js.map +1 -1
  14. package/dist/commands/build.d.ts +14 -0
  15. package/dist/commands/build.d.ts.map +1 -1
  16. package/dist/commands/build.js +89 -24
  17. package/dist/commands/build.js.map +1 -1
  18. package/dist/commands/claude/marketplace/validate.d.ts +97 -0
  19. package/dist/commands/claude/marketplace/validate.d.ts.map +1 -1
  20. package/dist/commands/claude/marketplace/validate.js +154 -40
  21. package/dist/commands/claude/marketplace/validate.js.map +1 -1
  22. package/dist/commands/claude/org/stubs.d.ts +9 -1
  23. package/dist/commands/claude/org/stubs.d.ts.map +1 -1
  24. package/dist/commands/claude/org/stubs.js +11 -3
  25. package/dist/commands/claude/org/stubs.js.map +1 -1
  26. package/dist/commands/claude/plugin/build.d.ts +25 -2
  27. package/dist/commands/claude/plugin/build.d.ts.map +1 -1
  28. package/dist/commands/claude/plugin/build.js +99 -10
  29. package/dist/commands/claude/plugin/build.js.map +1 -1
  30. package/dist/commands/claude/plugin/plugin-json-merge.d.ts.map +1 -1
  31. package/dist/commands/claude/plugin/plugin-json-merge.js +15 -0
  32. package/dist/commands/claude/plugin/plugin-json-merge.js.map +1 -1
  33. package/dist/commands/corpus/report.d.ts +22 -4
  34. package/dist/commands/corpus/report.d.ts.map +1 -1
  35. package/dist/commands/corpus/report.js +1 -0
  36. package/dist/commands/corpus/report.js.map +1 -1
  37. package/dist/commands/corpus/runner.d.ts +16 -1
  38. package/dist/commands/corpus/runner.d.ts.map +1 -1
  39. package/dist/commands/corpus/runner.js +40 -22
  40. package/dist/commands/corpus/runner.js.map +1 -1
  41. package/dist/commands/inventory.d.ts +2 -0
  42. package/dist/commands/inventory.d.ts.map +1 -1
  43. package/dist/commands/inventory.js +45 -4
  44. package/dist/commands/inventory.js.map +1 -1
  45. package/dist/commands/phase-utils.d.ts +183 -14
  46. package/dist/commands/phase-utils.d.ts.map +1 -1
  47. package/dist/commands/phase-utils.js +294 -29
  48. package/dist/commands/phase-utils.js.map +1 -1
  49. package/dist/commands/rag/query-command.d.ts +40 -0
  50. package/dist/commands/rag/query-command.d.ts.map +1 -1
  51. package/dist/commands/rag/query-command.js +47 -20
  52. package/dist/commands/rag/query-command.js.map +1 -1
  53. package/dist/commands/resources/index.d.ts.map +1 -1
  54. package/dist/commands/resources/index.js +27 -9
  55. package/dist/commands/resources/index.js.map +1 -1
  56. package/dist/commands/resources/scan.d.ts +37 -0
  57. package/dist/commands/resources/scan.d.ts.map +1 -1
  58. package/dist/commands/resources/scan.js +47 -32
  59. package/dist/commands/resources/scan.js.map +1 -1
  60. package/dist/commands/resources/validate.d.ts +136 -1
  61. package/dist/commands/resources/validate.d.ts.map +1 -1
  62. package/dist/commands/resources/validate.js +105 -66
  63. package/dist/commands/resources/validate.js.map +1 -1
  64. package/dist/commands/skill/review.d.ts.map +1 -1
  65. package/dist/commands/skill/review.js +7 -3
  66. package/dist/commands/skill/review.js.map +1 -1
  67. package/dist/commands/skill/test/run.d.ts +1 -1
  68. package/dist/commands/skill/test/run.d.ts.map +1 -1
  69. package/dist/commands/skill/test/run.js +8 -6
  70. package/dist/commands/skill/test/run.js.map +1 -1
  71. package/dist/commands/skills/build.d.ts +96 -7
  72. package/dist/commands/skills/build.d.ts.map +1 -1
  73. package/dist/commands/skills/build.js +277 -60
  74. package/dist/commands/skills/build.js.map +1 -1
  75. package/dist/commands/skills/command-helpers.d.ts +6 -10
  76. package/dist/commands/skills/command-helpers.d.ts.map +1 -1
  77. package/dist/commands/skills/command-helpers.js +6 -22
  78. package/dist/commands/skills/command-helpers.js.map +1 -1
  79. package/dist/commands/skills/list.d.ts +18 -0
  80. package/dist/commands/skills/list.d.ts.map +1 -1
  81. package/dist/commands/skills/list.js +41 -15
  82. package/dist/commands/skills/list.js.map +1 -1
  83. package/dist/commands/skills/package.d.ts +15 -0
  84. package/dist/commands/skills/package.d.ts.map +1 -1
  85. package/dist/commands/skills/package.js +30 -20
  86. package/dist/commands/skills/package.js.map +1 -1
  87. package/dist/commands/skills/validate-command.d.ts.map +1 -1
  88. package/dist/commands/skills/validate-command.js +22 -6
  89. package/dist/commands/skills/validate-command.js.map +1 -1
  90. package/dist/commands/skills/validate.d.ts +44 -3
  91. package/dist/commands/skills/validate.d.ts.map +1 -1
  92. package/dist/commands/skills/validate.js +180 -46
  93. package/dist/commands/skills/validate.js.map +1 -1
  94. package/dist/commands/validate.d.ts +18 -0
  95. package/dist/commands/validate.d.ts.map +1 -1
  96. package/dist/commands/validate.js +73 -66
  97. package/dist/commands/validate.js.map +1 -1
  98. package/dist/commands/verify.d.ts +38 -1
  99. package/dist/commands/verify.d.ts.map +1 -1
  100. package/dist/commands/verify.js +203 -83
  101. package/dist/commands/verify.js.map +1 -1
  102. package/dist/index.d.ts +1 -1
  103. package/dist/index.d.ts.map +1 -1
  104. package/dist/index.js +7 -1
  105. package/dist/index.js.map +1 -1
  106. package/dist/skill-resolution/index.d.ts +1 -1
  107. package/dist/skill-resolution/index.d.ts.map +1 -1
  108. package/dist/skill-resolution/index.js +1 -1
  109. package/dist/skill-resolution/index.js.map +1 -1
  110. package/dist/skill-resolution/packaging-config.d.ts +15 -2
  111. package/dist/skill-resolution/packaging-config.d.ts.map +1 -1
  112. package/dist/skill-resolution/packaging-config.js +39 -2
  113. package/dist/skill-resolution/packaging-config.js.map +1 -1
  114. package/dist/utils/command-error.d.ts +34 -1
  115. package/dist/utils/command-error.d.ts.map +1 -1
  116. package/dist/utils/command-error.js +32 -1
  117. package/dist/utils/command-error.js.map +1 -1
  118. package/dist/utils/help-loader.d.ts +6 -2
  119. package/dist/utils/help-loader.d.ts.map +1 -1
  120. package/dist/utils/help-loader.js +9 -16
  121. package/dist/utils/help-loader.js.map +1 -1
  122. package/dist/utils/issue-rendering.d.ts +88 -0
  123. package/dist/utils/issue-rendering.d.ts.map +1 -1
  124. package/dist/utils/issue-rendering.js +94 -0
  125. package/dist/utils/issue-rendering.js.map +1 -1
  126. package/dist/utils/output.d.ts +54 -5
  127. package/dist/utils/output.d.ts.map +1 -1
  128. package/dist/utils/output.js +111 -17
  129. package/dist/utils/output.js.map +1 -1
  130. package/dist/utils/relativize-paths.d.ts +45 -0
  131. package/dist/utils/relativize-paths.d.ts.map +1 -0
  132. package/dist/utils/relativize-paths.js +67 -0
  133. package/dist/utils/relativize-paths.js.map +1 -0
  134. package/dist/utils/resource-loader.d.ts +21 -2
  135. package/dist/utils/resource-loader.d.ts.map +1 -1
  136. package/dist/utils/resource-loader.js +85 -11
  137. package/dist/utils/resource-loader.js.map +1 -1
  138. package/dist/utils/skill-packaging-config.d.ts +22 -1
  139. package/dist/utils/skill-packaging-config.d.ts.map +1 -1
  140. package/dist/utils/skill-packaging-config.js +26 -0
  141. package/dist/utils/skill-packaging-config.js.map +1 -1
  142. package/dist/utils/verdict-helpers.d.ts +4 -3
  143. package/dist/utils/verdict-helpers.d.ts.map +1 -1
  144. package/dist/utils/verdict-helpers.js +6 -13
  145. package/dist/utils/verdict-helpers.js.map +1 -1
  146. package/docs/rag.md +9 -4
  147. package/docs/resources.md +30 -14
  148. package/docs/skills.md +9 -5
  149. package/package.json +12 -12
@@ -11,12 +11,13 @@ import { detectFormat } from '@vibe-agent-toolkit/discovery';
11
11
  import { findProjectRoot, gitFindRoot, GitTracker, isAbsolutePath, isGitUrl, issueLocation, parseGitUrl, resetProjectRootCaches, safePath, } from '@vibe-agent-toolkit/utils';
12
12
  import { Command } from 'commander';
13
13
  import picomatch from 'picomatch';
14
- import { resetSkillDiscoveryCache, resolveSkillPackagingConfig, stripValidationAllowForDisplay, } from '../skill-resolution/packaging-config.js';
14
+ import { resetSkillDiscoveryCache, resolveProjectDeclaredEvalSuites, resolveSkillPackagingConfig, stripValidationAllowForDisplay, } from '../skill-resolution/packaging-config.js';
15
15
  import { handleCommandError } from '../utils/command-error.js';
16
16
  import { ConfigLoadError, loadConfig, resetLoadedConfigCache, } from '../utils/config-loader.js';
17
- import { formatIssueAnchor } from '../utils/issue-anchor.js';
17
+ import { formatIssueLines, formatSeverityBreakdown, issuesToRenderAtVerbosity, } from '../utils/issue-rendering.js';
18
18
  import { createLogger } from '../utils/logger.js';
19
19
  import { writeYamlOutput } from '../utils/output.js';
20
+ import { relativizePathEntries } from '../utils/relativize-paths.js';
20
21
  import { mergeSkillPackagingConfig } from '../utils/skill-packaging-config.js';
21
22
  import { renderSkillQualityFooter } from '../utils/skill-quality-footer.js';
22
23
  import { computeConfigVerdicts } from '../utils/verdict-helpers.js';
@@ -168,15 +169,19 @@ async function validateSingleSkill(skillPath, options, logger, locationRoot, isV
168
169
  const skillConfig = fullConfig === null ? null : stripValidationAllowForDisplay(fullConfig);
169
170
  if (skillConfig !== null) {
170
171
  logger.debug(` Using config-aware validation for: ${skillPath}`);
171
- const { gitTracker } = await resolveScanContext(safePath.resolve(skillPath), locationRoot);
172
+ const { gitTracker } = await resolveScanContext(safePath.resolve(skillPath), locationRoot, logger);
172
173
  // Key the shared registry on the same project root the validator derives,
173
174
  // so every skill under one root parses the corpus once for the whole run
174
175
  // instead of once each.
175
176
  const skillDir = safePath.resolve(skillPath, '..');
176
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.
177
181
  const sharedCtx = {
178
182
  registry: await crawlAndResolveRegistry(projectRoot),
179
183
  locationRoot,
184
+ projectSkills: await resolveProjectDeclaredEvalSuites(skillPath),
180
185
  };
181
186
  if (gitTracker !== null) {
182
187
  sharedCtx.gitTracker = gitTracker;
@@ -368,7 +373,7 @@ async function auditUserDirectories(recursive, options, startTime, logger) {
368
373
  }
369
374
  // Run compatibility analysis if --compat flag is set
370
375
  const compatMap = options.compat
371
- ? await runCompatAnalysis(results, logger) // --settings not supported in --user mode
376
+ ? await runCompatAnalysis(results, logger, scanRoot) // --settings not supported in --user mode
372
377
  : undefined;
373
378
  const verbose = options.verbose ?? false;
374
379
  const skillResults = results.filter((r) => SKILL_RESULT_TYPES.has(r.type));
@@ -539,7 +544,7 @@ export async function buildAuditReport(scanPath, options, startTime, logger) {
539
544
  const vatContextForCompat = await buildVATProjectContext(deriveConfigRoot(scanPath), logger);
540
545
  // Run compatibility analysis if --compat flag is set
541
546
  const compatMap = options.compat
542
- ? await runCompatAnalysis(results, logger, effectiveSettings, vatContextForCompat)
547
+ ? await runCompatAnalysis(results, logger, scanRoot, effectiveSettings, vatContextForCompat)
543
548
  : undefined;
544
549
  const verbose = options.verbose ?? false;
545
550
  return {
@@ -570,7 +575,7 @@ async function runAuditAtPath(scanPath, options, overrides = {}) {
570
575
  if (verbose) {
571
576
  renderVerboseEvidence(results, document.root, logger);
572
577
  }
573
- handleAuditResults(results, document, logger);
578
+ handleAuditResults(results, document, logger, verbose);
574
579
  logger.debug(`Audit complete in ${Date.now() - startTime}ms`);
575
580
  }
576
581
  async function runUrlAudit(rawInput, options) {
@@ -813,14 +818,11 @@ export async function getValidationResults(scanPath, recursive, options, logger,
813
818
  const stat = await fsp.stat(scanPath);
814
819
  if (stat.isDirectory()) {
815
820
  logger.debug('Scanning directory for resources');
816
- // Merge resources.exclude from config with --exclude CLI flag patterns.
817
- // Both use the same picomatch semantics. Do NOT mutate options.
818
- const config = loadConfig(deriveConfigRoot(scanPath));
819
- const configExcludes = config?.resources?.exclude ?? [];
820
- const mergedOptions = configExcludes.length > 0
821
- ? { ...options, exclude: [...(options.exclude ?? []), ...configExcludes] }
822
- : options;
823
- return scanDirectory(scanPath, recursive, mergedOptions, logger, await resolveScanContext(scanPath, locationRoot));
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));
824
826
  }
825
827
  }
826
828
  catch {
@@ -895,6 +897,11 @@ function resolveConfigTargetsForPlugin(pluginDir, vatContext) {
895
897
  * Non-plugin results are skipped silently.
896
898
  * When effectiveSettings is provided, also runs settings conflict detection.
897
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
+ *
898
905
  * When vatContext is provided, config-layer `targets` declarations are
899
906
  * threaded through to the analyzer so `vat audit .` inside a VAT project
900
907
  * matches `vat skills validate` verdicts. See
@@ -902,7 +909,7 @@ function resolveConfigTargetsForPlugin(pluginDir, vatContext) {
902
909
  *
903
910
  * @internal Exported for integration testing only — not part of the public CLI API.
904
911
  */
905
- export async function runCompatAnalysis(results, logger, effectiveSettings, vatContext = null) {
912
+ export async function runCompatAnalysis(results, logger, locationRoot, effectiveSettings, vatContext = null) {
906
913
  const compatMap = new Map();
907
914
  for (const result of results) {
908
915
  if (result.type !== RESOURCE_TYPE_CLAUDE_PLUGIN)
@@ -913,7 +920,7 @@ export async function runCompatAnalysis(results, logger, effectiveSettings, vatC
913
920
  const analyzeOptions = configTargets === undefined
914
921
  ? undefined
915
922
  : { configTargets };
916
- const compat = await analyzeCompatibility(result.path, analyzeOptions);
923
+ const compat = await analyzeCompatibility(result.path, locationRoot, analyzeOptions);
917
924
  // Settings conflict detection (when --settings flag is used)
918
925
  if (effectiveSettings === undefined) {
919
926
  compatMap.set(result.path, compat);
@@ -994,34 +1001,22 @@ function applyVerboseFilter(results, verbose) {
994
1001
  });
995
1002
  }
996
1003
  /**
997
- * Re-base each emitted file entry's `path` onto the run root.
1004
+ * Nested carriers inside a file entry that publish their own `path`.
998
1005
  *
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
+ * `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.
1006
1010
  */
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
- }
1011
+ const NESTED_PATH_CARRIERS = ['linkedFiles'];
1017
1012
  function calculateSummary(results, startTime, compatMap, verbose, root) {
1018
- const base = buildBaseSummary(results, startTime);
1019
- const withCompat = applyCompatMap(results, compatMap);
1013
+ const { entries, ...base } = buildBaseSummary(results, startTime);
1014
+ const withCompat = applyCompatMap(entries, compatMap);
1020
1015
  return {
1021
1016
  // Stated once, first, and the only absolute path in the document.
1022
1017
  root,
1023
1018
  ...base,
1024
- files: relativizeFileEntries(applyVerboseFilter(withCompat, verbose), root),
1019
+ files: relativizePathEntries(applyVerboseFilter(withCompat, verbose), root, NESTED_PATH_CARRIERS),
1025
1020
  };
1026
1021
  }
1027
1022
  /**
@@ -1088,7 +1083,7 @@ function renderVerboseEvidence(results, root, logger) {
1088
1083
  }
1089
1084
  }
1090
1085
  }
1091
- function handleAuditResults(results, summary, logger) {
1086
+ function handleAuditResults(results, summary, logger, verbose) {
1092
1087
  const { filesWithErrors: errorCount, filesWithWarnings: warningCount, filesPassed: successCount, } = summary.summary;
1093
1088
  // Report settings conflicts (advisory, non-blocking)
1094
1089
  const totalSettingsConflicts = (summary.files ?? []).reduce((sum, f) => {
@@ -1100,10 +1095,12 @@ function handleAuditResults(results, summary, logger) {
1100
1095
  // Audit is advisory only — always exit 0 for validation results.
1101
1096
  // Use vat skills validate for gated validation (exit 1 on errors).
1102
1097
  if (errorCount > 0) {
1103
- logErrors(results, errorCount, summary.root, logger);
1098
+ logger.error(`Audit failed: ${errorCount} file(s) with errors`);
1099
+ logFindingsForStatus(results, 'error', summary.root, logger.error.bind(logger), verbose);
1104
1100
  }
1105
1101
  else if (warningCount > 0) {
1106
- logWarnings(results, warningCount, summary.root, logger);
1102
+ logger.info(`Audit passed with warnings: ${warningCount} file(s)`);
1103
+ logFindingsForStatus(results, 'warning', summary.root, logger.info.bind(logger), verbose);
1107
1104
  }
1108
1105
  else {
1109
1106
  // `success` means "nothing actionable", NOT "nothing to see" — name the
@@ -1135,34 +1132,62 @@ function renderAuditFooter(results, logger) {
1135
1132
  }
1136
1133
  renderSkillQualityFooter(logger, hasSkillFindings, emittedCodes);
1137
1134
  }
1138
- function logErrors(results, errorCount, root, logger) {
1139
- logger.error(`Audit failed: ${errorCount} file(s) with errors`);
1140
- const errorResults = results.filter((r) => r.status === 'error');
1141
- for (const result of errorResults) {
1142
- logger.error(`\n${issueLocation(result.path, root)}:`);
1143
- const errorIssues = result.issues.filter((i) => i.severity === 'error');
1144
- logIssues(errorIssues, logger.error.bind(logger));
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
+ }
1145
1174
  }
1146
- }
1147
- function logWarnings(results, warningCount, root, logger) {
1148
- logger.info(`Audit passed with warnings: ${warningCount} file(s)`);
1149
- const warningResults = results.filter((r) => r.status === 'warning');
1150
- for (const result of warningResults) {
1151
- logger.info(`\n${issueLocation(result.path, root)}:`);
1152
- const warningIssues = result.issues.filter((i) => i.severity === 'warning');
1153
- logIssues(warningIssues, logger.info.bind(logger));
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.');
1154
1178
  }
1179
+ return lines;
1155
1180
  }
1156
- function logIssues(issues, logFn) {
1157
- for (const issue of issues) {
1158
- logFn(` [${issue.code}] ${issue.message}`);
1159
- const anchor = formatIssueAnchor(issue);
1160
- if (anchor !== undefined) {
1161
- logFn(` at: ${anchor}`);
1162
- }
1163
- if (issue.fix) {
1164
- logFn(` fix: ${issue.fix}`);
1165
- }
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);
1166
1191
  }
1167
1192
  }
1168
1193
  /**
@@ -1198,9 +1223,12 @@ async function handleFileEntry(entry, fullPath, options, logger, scanCtx) {
1198
1223
  logger.debug(` Using config-aware validation for: ${fullPath}`);
1199
1224
  // Thread the per-scan tracker into packaging validation so gitignore
1200
1225
  // checks in the link-graph walk stay O(1).
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);
1201
1229
  const sharedCtx = scanCtx.gitTracker === null
1202
- ? { locationRoot }
1203
- : { gitTracker: scanCtx.gitTracker, locationRoot };
1230
+ ? { locationRoot, projectSkills }
1231
+ : { gitTracker: scanCtx.gitTracker, locationRoot, projectSkills };
1204
1232
  const packagingResult = await validateSkillForPackaging(fullPath, skillConfig, 'source', sharedCtx);
1205
1233
  return packagingResultToValidationResult(fullPath, packagingResult, skillConfig.targets, locationRoot);
1206
1234
  }
@@ -1237,6 +1265,52 @@ async function handleDirectoryEntry(fullPath, recursive, options, logger, baseDi
1237
1265
  }
1238
1266
  return results;
1239
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
+ }
1240
1314
  /** Cache of (gitRoot → initialized GitTracker) to avoid re-spawning ls-files. */
1241
1315
  const gitTrackerCache = new Map();
1242
1316
  /**
@@ -1264,10 +1338,27 @@ async function getOrCreateInventoryRegistry(projectRoot) {
1264
1338
  /**
1265
1339
  * `extractClaudePluginInventory` for a plugin directory, handing it the shared
1266
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.
1267
1358
  */
1268
1359
  async function pluginInventoryAt(dir) {
1269
- const root = findProjectRoot(dir) ?? dir;
1270
- return extractClaudePluginInventory(dir, await getOrCreateInventoryRegistry(root));
1360
+ const projectRoot = findProjectRoot(dir);
1361
+ return extractClaudePluginInventory(dir, projectRoot === null ? undefined : () => getOrCreateInventoryRegistry(projectRoot));
1271
1362
  }
1272
1363
  /**
1273
1364
  * Reset all module-level audit caches. Must be called at the start of every
@@ -1303,18 +1394,19 @@ async function getOrCreateGitTracker(gitRoot) {
1303
1394
  * `dist/`), returns `{ gitRoot: null, gitTracker: null }` to disable gitignore
1304
1395
  * filtering — the user's explicit intent takes precedence over .gitignore.
1305
1396
  */
1306
- async function resolveScanContext(dirPath, locationRoot) {
1397
+ async function resolveScanContext(dirPath, locationRoot, logger) {
1398
+ const projectExcludes = resolveProjectExcludes(dirPath, logger);
1307
1399
  const detectedRoot = gitFindRoot(dirPath);
1308
1400
  if (detectedRoot === null) {
1309
- return { gitRoot: null, gitTracker: null, locationRoot };
1401
+ return { gitRoot: null, gitTracker: null, locationRoot, projectExcludes };
1310
1402
  }
1311
1403
  const tracker = await getOrCreateGitTracker(detectedRoot);
1312
1404
  // If the scan root itself is gitignored (e.g. user targeted `dist/`), disable
1313
1405
  // gitignore filtering so the user's explicit intent wins.
1314
1406
  if (tracker.isIgnoredByActiveSet(dirPath)) {
1315
- return { gitRoot: null, gitTracker: null, locationRoot };
1407
+ return { gitRoot: null, gitTracker: null, locationRoot, projectExcludes };
1316
1408
  }
1317
- return { gitRoot: detectedRoot, gitTracker: tracker, locationRoot };
1409
+ return { gitRoot: detectedRoot, gitTracker: tracker, locationRoot, projectExcludes };
1318
1410
  }
1319
1411
  /**
1320
1412
  * Build the gitignore exclusion map for a set of entry paths.
@@ -1335,7 +1427,7 @@ function buildGitIgnoreMap(entryPaths, gitTracker, includeArtifacts) {
1335
1427
  * Check whether a directory entry should be skipped during scanning.
1336
1428
  * Returns a reason string for debug logging, or null if the entry should be kept.
1337
1429
  */
1338
- function getSkipReason(entry, fullPath, resolvedBaseDir, gitIgnoredMap, isMatch) {
1430
+ function getSkipReason(entry, fullPath, gitIgnoredMap, excludeMatchers) {
1339
1431
  // Always skip .git directory (not reported by git check-ignore)
1340
1432
  if (entry.isDirectory() && entry.name === '.git') {
1341
1433
  return '.git';
@@ -1344,11 +1436,11 @@ function getSkipReason(entry, fullPath, resolvedBaseDir, gitIgnoredMap, isMatch)
1344
1436
  if (gitIgnoredMap !== null && gitIgnoredMap.get(fullPath) === true) {
1345
1437
  return `gitignored: ${entry.name}`;
1346
1438
  }
1347
- // Check user-supplied --exclude patterns
1348
- if (isMatch !== null) {
1349
- const relativePath = safePath.relative(resolvedBaseDir, fullPath).replaceAll('\\', '/');
1350
- if (isExcludedPath(isMatch, relativePath, entry.isDirectory())) {
1351
- return `excluded: ${relativePath}`;
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}`;
1352
1444
  }
1353
1445
  }
1354
1446
  return null;
@@ -1381,14 +1473,26 @@ async function scanDirectory(dirPath, recursive, options, logger, scanCtx, baseD
1381
1473
  // Compile picomatch for user-supplied --exclude patterns.
1382
1474
  // dot:true so excludes like `**/private/*` match through dotfile dirs
1383
1475
  // (`.claude/.../private/x`); without it the exclude silently never fires.
1384
- 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
+ }
1385
1489
  const entries = await fs.readdir(dirPath, { withFileTypes: true });
1386
1490
  // Build full paths and batch-check gitignore status
1387
1491
  const entryPaths = entries.map(e => safePath.join(dirPath, e.name));
1388
1492
  const gitIgnoredMap = buildGitIgnoreMap(entryPaths, resolvedScanCtx.gitTracker, options.includeArtifacts ?? false);
1389
1493
  for (const entry of entries) {
1390
1494
  const fullPath = safePath.join(dirPath, entry.name);
1391
- const skipReason = getSkipReason(entry, fullPath, resolvedBaseDir, gitIgnoredMap, isMatch);
1495
+ const skipReason = getSkipReason(entry, fullPath, gitIgnoredMap, excludeMatchers);
1392
1496
  if (skipReason !== null) {
1393
1497
  logger.debug(`Excluding path: ${skipReason}`);
1394
1498
  continue;
@@ -1441,17 +1545,45 @@ function countFilesByStatus(results) {
1441
1545
  };
1442
1546
  }
1443
1547
  /**
1444
- * 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.
1445
1550
  *
1446
1551
  * `issueCounts` is named exactly as it is on every individual file entry, and
1447
1552
  * means the same thing at both levels: findings, by severity. `summary` only ever
1448
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.
1449
1572
  */
1450
1573
  function buildBaseSummary(results, startTime) {
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
+ });
1451
1582
  return {
1452
- status: calculateOverallStatus(results),
1453
- summary: countFilesByStatus(results),
1454
- issueCounts: countBySeverity(results.flatMap((r) => r.issues)),
1583
+ entries,
1584
+ status: calculateOverallStatus(entries),
1585
+ summary: countFilesByStatus(entries),
1586
+ issueCounts,
1455
1587
  duration: `${Date.now() - startTime}ms`,
1456
1588
  };
1457
1589
  }
@@ -1459,8 +1591,8 @@ function buildBaseSummary(results, startTime) {
1459
1591
  * Calculate summary for hierarchical output
1460
1592
  */
1461
1593
  function calculateHierarchicalSummary(results, hierarchical, startTime, compatMap, verbose, root) {
1462
- const base = buildBaseSummary(results, startTime);
1463
- const withCompat = applyCompatMap(results, compatMap);
1594
+ const { entries, ...base } = buildBaseSummary(results, startTime);
1595
+ const withCompat = applyCompatMap(entries, compatMap);
1464
1596
  return {
1465
1597
  root,
1466
1598
  ...base,
@@ -1471,7 +1603,7 @@ function calculateHierarchicalSummary(results, hierarchical, startTime, compatMa
1471
1603
  standalonePlugins: hierarchical.standalonePlugins.length,
1472
1604
  standaloneSkills: hierarchical.standaloneSkills.length,
1473
1605
  },
1474
- files: relativizeFileEntries(applyVerboseFilter(withCompat, verbose), root),
1606
+ files: relativizePathEntries(applyVerboseFilter(withCompat, verbose), root, NESTED_PATH_CARRIERS),
1475
1607
  hierarchical,
1476
1608
  };
1477
1609
  }