@vibe-agent-toolkit/agent-skills 0.1.42 → 0.2.0-rc.10

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 (322) hide show
  1. package/README.md +5 -5
  2. package/dist/builder.d.ts.map +1 -1
  3. package/dist/builder.js +7 -17
  4. package/dist/builder.js.map +1 -1
  5. package/dist/content-type-routing.d.ts +32 -0
  6. package/dist/content-type-routing.d.ts.map +1 -1
  7. package/dist/content-type-routing.js +23 -0
  8. package/dist/content-type-routing.js.map +1 -1
  9. package/dist/files-config.d.ts +34 -1
  10. package/dist/files-config.d.ts.map +1 -1
  11. package/dist/files-config.js +277 -39
  12. package/dist/files-config.js.map +1 -1
  13. package/dist/fs-attribution.d.ts +45 -0
  14. package/dist/fs-attribution.d.ts.map +1 -0
  15. package/dist/fs-attribution.js +57 -0
  16. package/dist/fs-attribution.js.map +1 -0
  17. package/dist/import.d.ts.map +1 -1
  18. package/dist/import.js +0 -4
  19. package/dist/import.js.map +1 -1
  20. package/dist/index.d.ts +13 -8
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +25 -7
  23. package/dist/index.js.map +1 -1
  24. package/dist/inventory/detectors/declared-but-missing.d.ts +1 -1
  25. package/dist/inventory/detectors/declared-but-missing.d.ts.map +1 -1
  26. package/dist/inventory/detectors/marketplace-source-missing.d.ts +1 -1
  27. package/dist/inventory/detectors/marketplace-source-missing.d.ts.map +1 -1
  28. package/dist/inventory/detectors/present-but-undeclared.d.ts +1 -1
  29. package/dist/inventory/detectors/present-but-undeclared.d.ts.map +1 -1
  30. package/dist/inventory/detectors/reference-target-missing.d.ts +1 -1
  31. package/dist/inventory/detectors/reference-target-missing.d.ts.map +1 -1
  32. package/dist/inventory/index.d.ts +1 -1
  33. package/dist/inventory/index.d.ts.map +1 -1
  34. package/dist/inventory/index.js +1 -1
  35. package/dist/inventory/index.js.map +1 -1
  36. package/dist/inventory/serialize.d.ts +4 -4
  37. package/dist/inventory/serialize.d.ts.map +1 -1
  38. package/dist/inventory/serialize.js +6 -6
  39. package/dist/inventory/serialize.js.map +1 -1
  40. package/dist/inventory/types.d.ts +8 -0
  41. package/dist/inventory/types.d.ts.map +1 -1
  42. package/dist/inventory/types.js.map +1 -1
  43. package/dist/link-graph-members.d.ts +18 -0
  44. package/dist/link-graph-members.d.ts.map +1 -0
  45. package/dist/link-graph-members.js +18 -0
  46. package/dist/link-graph-members.js.map +1 -0
  47. package/dist/parsers/frontmatter-parser.d.ts.map +1 -1
  48. package/dist/parsers/frontmatter-parser.js +30 -6
  49. package/dist/parsers/frontmatter-parser.js.map +1 -1
  50. package/dist/plugin-distribution-layout.d.ts +70 -6
  51. package/dist/plugin-distribution-layout.d.ts.map +1 -1
  52. package/dist/plugin-distribution-layout.js +84 -11
  53. package/dist/plugin-distribution-layout.js.map +1 -1
  54. package/dist/post-build-checks.d.ts +38 -1
  55. package/dist/post-build-checks.d.ts.map +1 -1
  56. package/dist/post-build-checks.js +68 -17
  57. package/dist/post-build-checks.js.map +1 -1
  58. package/dist/projection/skill-extent.d.ts +247 -0
  59. package/dist/projection/skill-extent.d.ts.map +1 -0
  60. package/dist/projection/skill-extent.js +560 -0
  61. package/dist/projection/skill-extent.js.map +1 -0
  62. package/dist/schemas/agent-skill-frontmatter.js +1 -1
  63. package/dist/schemas/agent-skill-frontmatter.js.map +1 -1
  64. package/dist/schemas/allowed-tools.d.ts +31 -0
  65. package/dist/schemas/allowed-tools.d.ts.map +1 -0
  66. package/dist/schemas/allowed-tools.js +88 -0
  67. package/dist/schemas/allowed-tools.js.map +1 -0
  68. package/dist/schemas/index.d.ts +1 -0
  69. package/dist/schemas/index.d.ts.map +1 -1
  70. package/dist/schemas/index.js +1 -0
  71. package/dist/schemas/index.js.map +1 -1
  72. package/dist/schemas/installed-plugins-registry.d.ts.map +1 -1
  73. package/dist/schemas/installed-plugins-registry.js +7 -1
  74. package/dist/schemas/installed-plugins-registry.js.map +1 -1
  75. package/dist/schemas/marketplace-manifest.d.ts.map +1 -1
  76. package/dist/schemas/marketplace-manifest.js +17 -6
  77. package/dist/schemas/marketplace-manifest.js.map +1 -1
  78. package/dist/skill-identity.d.ts +18 -3
  79. package/dist/skill-identity.d.ts.map +1 -1
  80. package/dist/skill-identity.js +25 -6
  81. package/dist/skill-identity.js.map +1 -1
  82. package/dist/skill-packager.d.ts +144 -20
  83. package/dist/skill-packager.d.ts.map +1 -1
  84. package/dist/skill-packager.js +447 -105
  85. package/dist/skill-packager.js.map +1 -1
  86. package/dist/skill-source/content-hash.d.ts.map +1 -1
  87. package/dist/skill-source/content-hash.js +21 -10
  88. package/dist/skill-source/content-hash.js.map +1 -1
  89. package/dist/skill-source/fetch-cache.d.ts.map +1 -1
  90. package/dist/skill-source/fetch-cache.js +0 -5
  91. package/dist/skill-source/fetch-cache.js.map +1 -1
  92. package/dist/skill-source/git-clone.d.ts +1 -1
  93. package/dist/skill-source/git-clone.d.ts.map +1 -1
  94. package/dist/skill-source/git-clone.js +10 -10
  95. package/dist/skill-source/git-clone.js.map +1 -1
  96. package/dist/skill-source/sources/url-source.d.ts.map +1 -1
  97. package/dist/skill-source/sources/url-source.js +2 -2
  98. package/dist/skill-source/sources/url-source.js.map +1 -1
  99. package/dist/skill-source/stage.d.ts.map +1 -1
  100. package/dist/skill-source/stage.js +0 -6
  101. package/dist/skill-source/stage.js.map +1 -1
  102. package/dist/skill-test/baseline-delta.d.ts +1575 -0
  103. package/dist/skill-test/baseline-delta.d.ts.map +1 -0
  104. package/dist/skill-test/baseline-delta.js +272 -0
  105. package/dist/skill-test/baseline-delta.js.map +1 -0
  106. package/dist/skill-test/baseline-integrity.d.ts +944 -0
  107. package/dist/skill-test/baseline-integrity.d.ts.map +1 -0
  108. package/dist/skill-test/baseline-integrity.js +3090 -0
  109. package/dist/skill-test/baseline-integrity.js.map +1 -0
  110. package/dist/skill-test/build-hook.d.ts +3 -2
  111. package/dist/skill-test/build-hook.d.ts.map +1 -1
  112. package/dist/skill-test/build-hook.js +4 -5
  113. package/dist/skill-test/build-hook.js.map +1 -1
  114. package/dist/skill-test/configure-writer.js +1 -1
  115. package/dist/skill-test/configure-writer.js.map +1 -1
  116. package/dist/skill-test/declared-env.d.ts +5 -4
  117. package/dist/skill-test/declared-env.d.ts.map +1 -1
  118. package/dist/skill-test/declared-env.js +8 -9
  119. package/dist/skill-test/declared-env.js.map +1 -1
  120. package/dist/skill-test/eval-executor.d.ts +21 -5
  121. package/dist/skill-test/eval-executor.d.ts.map +1 -1
  122. package/dist/skill-test/eval-executor.js +6 -6
  123. package/dist/skill-test/eval-executor.js.map +1 -1
  124. package/dist/skill-test/eval-fragment.d.ts +106 -28
  125. package/dist/skill-test/eval-fragment.d.ts.map +1 -1
  126. package/dist/skill-test/eval-fragment.js +78 -16
  127. package/dist/skill-test/eval-fragment.js.map +1 -1
  128. package/dist/skill-test/eval-grader.d.ts +65 -7
  129. package/dist/skill-test/eval-grader.d.ts.map +1 -1
  130. package/dist/skill-test/eval-grader.js +263 -64
  131. package/dist/skill-test/eval-grader.js.map +1 -1
  132. package/dist/skill-test/eval-inputs.d.ts +110 -8
  133. package/dist/skill-test/eval-inputs.d.ts.map +1 -1
  134. package/dist/skill-test/eval-inputs.js +177 -22
  135. package/dist/skill-test/eval-inputs.js.map +1 -1
  136. package/dist/skill-test/eval-lint.d.ts +17 -4
  137. package/dist/skill-test/eval-lint.d.ts.map +1 -1
  138. package/dist/skill-test/eval-lint.js +22 -9
  139. package/dist/skill-test/eval-lint.js.map +1 -1
  140. package/dist/skill-test/eval-suite-isolation.d.ts.map +1 -1
  141. package/dist/skill-test/eval-suite-isolation.js +8 -5
  142. package/dist/skill-test/eval-suite-isolation.js.map +1 -1
  143. package/dist/skill-test/evals-template.d.ts +1 -1
  144. package/dist/skill-test/evals-template.d.ts.map +1 -1
  145. package/dist/skill-test/evals-template.js +1 -3
  146. package/dist/skill-test/evals-template.js.map +1 -1
  147. package/dist/skill-test/executor-prompt.d.ts +15 -7
  148. package/dist/skill-test/executor-prompt.d.ts.map +1 -1
  149. package/dist/skill-test/executor-prompt.js +10 -7
  150. package/dist/skill-test/executor-prompt.js.map +1 -1
  151. package/dist/skill-test/failure-reason.d.ts +113 -0
  152. package/dist/skill-test/failure-reason.d.ts.map +1 -0
  153. package/dist/skill-test/failure-reason.js +124 -0
  154. package/dist/skill-test/failure-reason.js.map +1 -0
  155. package/dist/skill-test/fragment-merge.d.ts +3 -20
  156. package/dist/skill-test/fragment-merge.d.ts.map +1 -1
  157. package/dist/skill-test/fragment-merge.js +67 -8
  158. package/dist/skill-test/fragment-merge.js.map +1 -1
  159. package/dist/skill-test/grader-prompt.d.ts +29 -9
  160. package/dist/skill-test/grader-prompt.d.ts.map +1 -1
  161. package/dist/skill-test/grader-prompt.js +264 -68
  162. package/dist/skill-test/grader-prompt.js.map +1 -1
  163. package/dist/skill-test/grader-text.d.ts +145 -0
  164. package/dist/skill-test/grader-text.d.ts.map +1 -0
  165. package/dist/skill-test/grader-text.js +410 -0
  166. package/dist/skill-test/grader-text.js.map +1 -0
  167. package/dist/skill-test/grading-adapter.d.ts +38 -2
  168. package/dist/skill-test/grading-adapter.d.ts.map +1 -1
  169. package/dist/skill-test/grading-adapter.js +57 -9
  170. package/dist/skill-test/grading-adapter.js.map +1 -1
  171. package/dist/skill-test/grading-schema.d.ts +160 -5
  172. package/dist/skill-test/grading-schema.d.ts.map +1 -1
  173. package/dist/skill-test/grading-schema.js +66 -7
  174. package/dist/skill-test/grading-schema.js.map +1 -1
  175. package/dist/skill-test/harness-location.d.ts +3 -2
  176. package/dist/skill-test/harness-location.d.ts.map +1 -1
  177. package/dist/skill-test/harness-location.js +5 -16
  178. package/dist/skill-test/harness-location.js.map +1 -1
  179. package/dist/skill-test/index.d.ts +2 -2
  180. package/dist/skill-test/index.d.ts.map +1 -1
  181. package/dist/skill-test/index.js +2 -2
  182. package/dist/skill-test/index.js.map +1 -1
  183. package/dist/skill-test/lock.d.ts +58 -1
  184. package/dist/skill-test/lock.d.ts.map +1 -1
  185. package/dist/skill-test/lock.js +78 -6
  186. package/dist/skill-test/lock.js.map +1 -1
  187. package/dist/skill-test/pipeline.d.ts +19 -1
  188. package/dist/skill-test/pipeline.d.ts.map +1 -1
  189. package/dist/skill-test/pipeline.js +21 -10
  190. package/dist/skill-test/pipeline.js.map +1 -1
  191. package/dist/skill-test/preflight.d.ts +1 -1
  192. package/dist/skill-test/preflight.d.ts.map +1 -1
  193. package/dist/skill-test/preflight.js +59 -16
  194. package/dist/skill-test/preflight.js.map +1 -1
  195. package/dist/skill-test/prompt-invariants.d.ts +4 -2
  196. package/dist/skill-test/prompt-invariants.d.ts.map +1 -1
  197. package/dist/skill-test/prompt-invariants.js +5 -4
  198. package/dist/skill-test/prompt-invariants.js.map +1 -1
  199. package/dist/skill-test/run-harness.d.ts +665 -85
  200. package/dist/skill-test/run-harness.d.ts.map +1 -1
  201. package/dist/skill-test/run-harness.js +1522 -237
  202. package/dist/skill-test/run-harness.js.map +1 -1
  203. package/dist/skill-test/staging.d.ts +20 -8
  204. package/dist/skill-test/staging.d.ts.map +1 -1
  205. package/dist/skill-test/staging.js +25 -18
  206. package/dist/skill-test/staging.js.map +1 -1
  207. package/dist/skill-test/tier-plan.d.ts +1 -1
  208. package/dist/skill-test/tier-plan.js +1 -1
  209. package/dist/skill-test/tool-eval-schema.d.ts +6 -6
  210. package/dist/skill-test/tool-eval-schema.js +2 -2
  211. package/dist/skill-test/vendor-manifest.d.ts +2 -1
  212. package/dist/skill-test/vendor-manifest.d.ts.map +1 -1
  213. package/dist/skill-test/vendor-manifest.js +10 -10
  214. package/dist/skill-test/vendor-manifest.js.map +1 -1
  215. package/dist/test-input.d.ts +59 -8
  216. package/dist/test-input.d.ts.map +1 -1
  217. package/dist/test-input.js +64 -19
  218. package/dist/test-input.js.map +1 -1
  219. package/dist/validators/agent-instruction-presence.d.ts +1 -1
  220. package/dist/validators/agent-instruction-presence.d.ts.map +1 -1
  221. package/dist/validators/agent-instruction-presence.js +45 -2
  222. package/dist/validators/agent-instruction-presence.js.map +1 -1
  223. package/dist/validators/bundled-resource-link-detection.d.ts +6 -4
  224. package/dist/validators/bundled-resource-link-detection.d.ts.map +1 -1
  225. package/dist/validators/bundled-resource-link-detection.js +39 -39
  226. package/dist/validators/bundled-resource-link-detection.js.map +1 -1
  227. package/dist/validators/compat-detectors.d.ts +18 -1
  228. package/dist/validators/compat-detectors.d.ts.map +1 -1
  229. package/dist/validators/compat-detectors.js +47 -20
  230. package/dist/validators/compat-detectors.js.map +1 -1
  231. package/dist/validators/cross-skill-dependency-detection.d.ts +1 -1
  232. package/dist/validators/cross-skill-dependency-detection.d.ts.map +1 -1
  233. package/dist/validators/cross-skill-dependency-detection.js +1 -1
  234. package/dist/validators/cross-skill-dependency-detection.js.map +1 -1
  235. package/dist/validators/description-style-detection.d.ts +1 -1
  236. package/dist/validators/description-style-detection.d.ts.map +1 -1
  237. package/dist/validators/description-style-detection.js +1 -1
  238. package/dist/validators/description-style-detection.js.map +1 -1
  239. package/dist/validators/format-detection.d.ts +3 -0
  240. package/dist/validators/format-detection.d.ts.map +1 -1
  241. package/dist/validators/format-detection.js +41 -26
  242. package/dist/validators/format-detection.js.map +1 -1
  243. package/dist/validators/frontmatter-validation.d.ts +1 -1
  244. package/dist/validators/frontmatter-validation.d.ts.map +1 -1
  245. package/dist/validators/frontmatter-validation.js +2 -2
  246. package/dist/validators/frontmatter-validation.js.map +1 -1
  247. package/dist/validators/imperative-body-detection.d.ts +1 -1
  248. package/dist/validators/imperative-body-detection.d.ts.map +1 -1
  249. package/dist/validators/imperative-body-detection.js +1 -1
  250. package/dist/validators/imperative-body-detection.js.map +1 -1
  251. package/dist/validators/index.d.ts +1 -1
  252. package/dist/validators/index.d.ts.map +1 -1
  253. package/dist/validators/kebab-case-detection.d.ts +1 -1
  254. package/dist/validators/kebab-case-detection.d.ts.map +1 -1
  255. package/dist/validators/kebab-case-detection.js +1 -1
  256. package/dist/validators/kebab-case-detection.js.map +1 -1
  257. package/dist/validators/marketplace-validator.d.ts.map +1 -1
  258. package/dist/validators/marketplace-validator.js +7 -2
  259. package/dist/validators/marketplace-validator.js.map +1 -1
  260. package/dist/validators/mcp-tool-qualification.d.ts +158 -0
  261. package/dist/validators/mcp-tool-qualification.d.ts.map +1 -0
  262. package/dist/validators/mcp-tool-qualification.js +467 -0
  263. package/dist/validators/mcp-tool-qualification.js.map +1 -0
  264. package/dist/validators/packaged-size-limit.d.ts +296 -0
  265. package/dist/validators/packaged-size-limit.d.ts.map +1 -0
  266. package/dist/validators/packaged-size-limit.js +492 -0
  267. package/dist/validators/packaged-size-limit.js.map +1 -0
  268. package/dist/validators/packaging-validator.d.ts +149 -10
  269. package/dist/validators/packaging-validator.d.ts.map +1 -1
  270. package/dist/validators/packaging-validator.js +371 -55
  271. package/dist/validators/packaging-validator.js.map +1 -1
  272. package/dist/validators/plugin-hosted-shape.d.ts +4 -2
  273. package/dist/validators/plugin-hosted-shape.d.ts.map +1 -1
  274. package/dist/validators/plugin-hosted-shape.js +6 -7
  275. package/dist/validators/plugin-hosted-shape.js.map +1 -1
  276. package/dist/validators/plugin-recommended-fields.d.ts +1 -1
  277. package/dist/validators/plugin-recommended-fields.d.ts.map +1 -1
  278. package/dist/validators/plugin-recommended-fields.js +1 -1
  279. package/dist/validators/plugin-recommended-fields.js.map +1 -1
  280. package/dist/validators/referenced-path-missing.d.ts +211 -0
  281. package/dist/validators/referenced-path-missing.d.ts.map +1 -0
  282. package/dist/validators/referenced-path-missing.js +378 -0
  283. package/dist/validators/referenced-path-missing.js.map +1 -0
  284. package/dist/validators/registry-validator.d.ts.map +1 -1
  285. package/dist/validators/registry-validator.js +1 -2
  286. package/dist/validators/registry-validator.js.map +1 -1
  287. package/dist/validators/rule-engine/index.d.ts +1 -1
  288. package/dist/validators/rule-engine/rule-context.d.ts +20 -3
  289. package/dist/validators/rule-engine/rule-context.d.ts.map +1 -1
  290. package/dist/validators/rule-engine/rule-context.js +3 -1
  291. package/dist/validators/rule-engine/rule-context.js.map +1 -1
  292. package/dist/validators/rule-engine/rule-engine.d.ts +2 -2
  293. package/dist/validators/rule-engine/rule-engine.d.ts.map +1 -1
  294. package/dist/validators/rule-engine/rule-engine.js +52 -13
  295. package/dist/validators/rule-engine/rule-engine.js.map +1 -1
  296. package/dist/validators/skill-validator.d.ts.map +1 -1
  297. package/dist/validators/skill-validator.js +91 -54
  298. package/dist/validators/skill-validator.js.map +1 -1
  299. package/dist/validators/source-only-codes.js +1 -1
  300. package/dist/validators/source-only-codes.js.map +1 -1
  301. package/dist/validators/types.d.ts +21 -1
  302. package/dist/validators/types.d.ts.map +1 -1
  303. package/dist/validators/unified-validator.d.ts.map +1 -1
  304. package/dist/validators/unified-validator.js +4 -5
  305. package/dist/validators/unified-validator.js.map +1 -1
  306. package/dist/validators/validation-rules.d.ts +4 -4
  307. package/dist/validators/validation-rules.d.ts.map +1 -1
  308. package/dist/validators/validation-rules.js +9 -38
  309. package/dist/validators/validation-rules.js.map +1 -1
  310. package/dist/validators/walker-to-issues.d.ts +9 -3
  311. package/dist/validators/walker-to-issues.d.ts.map +1 -1
  312. package/dist/validators/walker-to-issues.js +64 -19
  313. package/dist/validators/walker-to-issues.js.map +1 -1
  314. package/dist/walk-link-graph.d.ts +64 -5
  315. package/dist/walk-link-graph.d.ts.map +1 -1
  316. package/dist/walk-link-graph.js +376 -92
  317. package/dist/walk-link-graph.js.map +1 -1
  318. package/package.json +14 -22
  319. package/dist/skill-test/exit-codes.d.ts +0 -97
  320. package/dist/skill-test/exit-codes.d.ts.map +0 -1
  321. package/dist/skill-test/exit-codes.js +0 -141
  322. package/dist/skill-test/exit-codes.js.map +0 -1
@@ -15,19 +15,21 @@
15
15
  import { existsSync, statSync } from 'node:fs';
16
16
  import { readFile } from 'node:fs/promises';
17
17
  import { basename, dirname } from 'node:path';
18
- import { CODE_REGISTRY, runSingleUnitValidation, runValidationFramework, } from '@vibe-agent-toolkit/agent-schema';
19
- import { DeferredArtifacts, parseMarkdown, ResourceRegistry } from '@vibe-agent-toolkit/resources';
20
- import { findProjectRoot, issueLocation, normalizedTmpdir, toForwardSlash, safePath } from '@vibe-agent-toolkit/utils';
18
+ import { DeferredArtifacts, listingRefusalRemedy, loadConfig, parseFileCached, ResourceRegistry, } from '@vibe-agent-toolkit/resources';
19
+ import { CODE_REGISTRY, runSingleUnitValidation, runValidationFramework, } from '@vibe-agent-toolkit/schema';
20
+ import { findProjectRoot, issueLocation, normalizedTmpdir, toForwardSlash, toForwardSlashAnyPlatform, safePath, } from '@vibe-agent-toolkit/utils';
21
+ import { DirectoryListingRefusedError } from '@vibe-agent-toolkit/utils/crawl';
21
22
  import { collectPreBuildGlobFindings, preBuildGlobFindingsToIssues } from '../files-config.js';
22
- import { packagedFileEntries, resolveTestInputDirs, testInputExcludeRules, testInputLinkIssues, } from '../test-input.js';
23
+ import { conventionalSuiteProbe, partitionTestInputFileEntries, resolveTestInputDirs, testInputExcludeRules, testInputLinkIssues, } from '../test-input.js';
23
24
  import { walkLinkGraph } from '../walk-link-graph.js';
24
25
  import { observationToIssue, runCompatDetectors } from './compat-detectors.js';
25
26
  import { detectUndeclaredCrossSkillAuth } from './cross-skill-dependency-detection.js';
26
27
  import { validateFrontmatterRules, validateFrontmatterSchema } from './frontmatter-validation.js';
28
+ import { collectUnqualifiedMcpToolIssues } from './mcp-tool-qualification.js';
27
29
  import { materializeIssue } from './rule-engine/index.js';
28
30
  import { SOURCE_ONLY_CODES } from './source-only-codes.js';
29
31
  import { VALIDATION_RULES, VALIDATION_THRESHOLDS, } from './validation-rules.js';
30
- import { deferredAssetsToIssues, walkerExclusionsToIssues } from './walker-to-issues.js';
32
+ import { deferredAssetsToIssues, outsideSkillDirLinksToIssues, walkerExclusionsToIssues } from './walker-to-issues.js';
31
33
  /** Exclude reason constants to avoid duplicate string literals */
32
34
  const EXCLUDE_REASON_DIRECTORY = 'directory-target';
33
35
  const EXCLUDE_REASON_OUTSIDE_PROJECT = 'outside-project';
@@ -60,7 +62,7 @@ function validateFilesConfig(files, projectRoot, locationRoot) {
60
62
  const issues = [];
61
63
  const destSet = new Set();
62
64
  for (const entry of files) {
63
- const normalized = toForwardSlash(entry.dest);
65
+ const normalized = toForwardSlashAnyPlatform(entry.dest);
64
66
  if (destSet.has(normalized)) {
65
67
  issues.push({
66
68
  severity: 'error',
@@ -72,7 +74,6 @@ function validateFilesConfig(files, projectRoot, locationRoot) {
72
74
  // Check if an existing source resolves to a directory — a typed single-file
73
75
  // slot cannot be satisfied by a directory.
74
76
  const resolvedSource = safePath.resolve(safePath.join(projectRoot, entry.source));
75
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- resolvedSource derived from config-supplied path
76
77
  if (existsSync(resolvedSource) && statSync(resolvedSource).isDirectory()) {
77
78
  // Anchor at the resolved source, expressed in the run's ONE coordinate
78
79
  // system — not the raw config value, which is project-relative and so
@@ -88,10 +89,10 @@ function validateFilesConfig(files, projectRoot, locationRoot) {
88
89
  * anchored at a project-relative `location`.
89
90
  *
90
91
  * Thin wrapper over the shared {@link materializeIssue} so severity / fix /
91
- * reference come from the single CODE_REGISTRY source (issue #129 dedup); the
92
+ * reference come from the single CODE_REGISTRY source; the
92
93
  * caller supplies a fully-formed `message`.
93
94
  *
94
- * Deliberately NOT named `createRegistryIssue`: `@vibe-agent-toolkit/agent-schema`
95
+ * Deliberately NOT named `createRegistryIssue`: `@vibe-agent-toolkit/schema`
95
96
  * exports a function by that name whose third parameter is an EXTRAS OBJECT, not
96
97
  * a location string. Two same-named functions with incompatible third arguments
97
98
  * is a trap for anyone reading a call site.
@@ -99,13 +100,28 @@ function validateFilesConfig(files, projectRoot, locationRoot) {
99
100
  function registryIssueAt(code, message, location) {
100
101
  return materializeIssue(code, { message, location });
101
102
  }
103
+ /**
104
+ * Settle the memoized refusals under THIS caller's policy — see {@link MemoizedRegistry}.
105
+ *
106
+ * @param refusals - What the one crawl of `projectRoot` was refused
107
+ * @param unreadable - This caller's ruling
108
+ * @param projectRoot - The crawl's root, for the refuse sentence
109
+ */
110
+ function replayRefusals(refusals, unreadable, projectRoot) {
111
+ for (const refusal of refusals) {
112
+ if (unreadable === 'refuse') {
113
+ throw new DirectoryListingRefusedError(refusal, { root: projectRoot, remedy: listingRefusalRemedy(projectRoot) });
114
+ }
115
+ unreadable.degrade(refusal);
116
+ }
117
+ }
102
118
  /**
103
119
  * Build a fresh ResourceRegistry for a single skill's projectRoot and resolve
104
120
  * internal links. Extracted so the skill validator can fall back to a private
105
121
  * registry when the caller does not supply a shared one.
106
122
  *
107
123
  * Crawls markdown AND HTML (`.html`/`.htm`) so the live audit/validate path
108
- * sees the same link graph the built path does (issue #129 AC2). The registry
124
+ * sees the same link graph the built path does. The registry
109
125
  * parses HTML via parse5 and surfaces its `local_file` links, so the walker
110
126
  * traverses HTML references and catches HTML broken links at source time — not
111
127
  * just at build time. (Previously the crawl was markdown-only, so source HTML
@@ -113,29 +129,89 @@ function registryIssueAt(code, message, location) {
113
129
  *
114
130
  * Exported so external callers (e.g. the inventory layer) can build a registry
115
131
  * once and pass it down rather than re-crawling per skill.
132
+ *
133
+ * ## Why the config is READ here rather than threaded in
134
+ *
135
+ * A collection may declare a `mimeType` that overrides `mime-type.ts`'s
136
+ * extension tables and so decides which parser runs; `ResourceRegistry` honours
137
+ * those declarations only when it was handed a config, and the projection lane
138
+ * behind `populationSource` reads them off the root. A config-less registry
139
+ * therefore disagrees with the population that enumerated it about whether a
140
+ * file is prose, inside a single command.
141
+ *
142
+ * Threading was considered and does not fit this function. Its callers discover
143
+ * `projectRoot` **per skill** (`findProjectRoot` from each SKILL.md's directory,
144
+ * and one `vat audit --user` run reached at least 72 distinct roots), so no
145
+ * caller holds one config that governs them all. Worse, the memo below is keyed
146
+ * on the root ALONE: an optional config argument would let two callers of one
147
+ * root disagree and silently share whichever registry was built first.
148
+ *
149
+ * Reading it here is what `createProjectRegistry` in `skill-packager.ts` already
150
+ * does, with the same `loadConfig`, for the same stated reason — "a registry
151
+ * built without config silently belongs to no collection, so a lane that built
152
+ * its own config-less registry rewrote frontmatter differently from the lane
153
+ * that used this one". One read per root per process, behind the memo, against a
154
+ * crawl that parses every document under it.
155
+ *
156
+ * ## A broken config falls back here, and is reported by the caller
157
+ *
158
+ * Unlike `createProjectRegistry`, this must NOT throw on a config that exists
159
+ * and will not parse. Its principal caller is `vat audit`, whose pinned
160
+ * behaviour is "tolerates, falls back to config-free validation" — a bulk linter
161
+ * over trees VAT does not own must not be aborted by someone else's YAML
162
+ * (`cli/test/integration/config-broken-behavior.integration.test.ts` is the map).
163
+ *
164
+ * The fallback is not the silent conflation that rule normally forbids, because
165
+ * nothing is being hidden: `vat audit` reads the same config at its own layer,
166
+ * catches `ConfigLoadError` there, and reports it. What this does is decline to
167
+ * abort a scan from four frames down. A config that will not parse also gives
168
+ * the projection lane no declarations, so the two lanes still AGREE — which is
169
+ * the property this function exists to hold.
116
170
  */
117
- export async function crawlAndResolveRegistry(projectRoot) {
171
+ export async function crawlAndResolveRegistry(projectRoot, options) {
172
+ const { populationSource, unreadable } = options;
173
+ const cache = registryCacheFor(populationSource);
118
174
  const key = toForwardSlash(safePath.resolve(projectRoot));
119
- const cached = registryCache.get(key);
175
+ const cached = cache.get(key);
120
176
  if (cached !== undefined) {
121
- return cached;
177
+ const { registry, refusals } = await cached;
178
+ replayRefusals(refusals, unreadable, projectRoot);
179
+ return registry;
122
180
  }
123
181
  // The PROMISE is cached, not the resolved value, so two overlapping requests
124
182
  // for one root can never start two crawls.
125
183
  const pending = (async () => {
184
+ const config = await loadConfig(projectRoot).catch(() => undefined);
185
+ // Every refusal the crawl hands over is kept for the memo's later callers —
186
+ // see {@link MemoizedRegistry}. Under `'refuse'` the crawl throws instead and
187
+ // this stays empty.
188
+ const refusals = [];
189
+ const recorded = unreadable === 'refuse'
190
+ ? 'refuse'
191
+ : { degrade: (refusal) => { refusals.push(refusal); unreadable.degrade(refusal); } };
126
192
  const registry = await ResourceRegistry.fromCrawl({
127
193
  baseDir: projectRoot,
128
194
  include: ['**/*.md', '**/*.html', '**/*.htm'],
129
- });
195
+ unreadable: recorded,
196
+ // Enumeration only. `ResourceRegistry.crawl` re-applies the include set
197
+ // above — and the crawl's default exclude — to whatever the source
198
+ // offers, through the same compiled matcher the walk itself uses, so a
199
+ // source that enumerates a whole tree still yields exactly this crawl's
200
+ // markdown and HTML. Handing this builder a projection is a cost change,
201
+ // not a scope change.
202
+ ...(populationSource !== undefined && { populationSource }),
203
+ }, config === undefined ? undefined : { config });
130
204
  registry.resolveLinks();
131
- return registry;
205
+ return { registry, refusals };
132
206
  })();
133
- registryCache.set(key, pending);
134
- return pending;
207
+ cache.set(key, pending);
208
+ return (await pending).registry;
135
209
  }
136
210
  /**
137
- * Cache of (resolved project root → registry), so the crawl above is paid ONCE
138
- * per root per process rather than once per skill.
211
+ * Cache of (resolved project root → registry) for the INCUMBENT WALK, so the
212
+ * crawl above is paid ONCE per root per process rather than once per skill.
213
+ * Source-backed callers get {@link sourcedRegistryCache} instead, and the two
214
+ * never meet — see there for why that separation is the whole point.
139
215
  *
140
216
  * It lives beside the crawl because every caller wants the same thing and the
141
217
  * cost is a property of the crawl, not of any one lane. `vat audit` had already
@@ -149,8 +225,81 @@ export async function crawlAndResolveRegistry(projectRoot) {
149
225
  * Safe to hold for a process lifetime: every VAT entry point is a short-lived
150
226
  * CLI invocation. Note the crawl excludes build output (`BUILD_OUTPUT_GLOBS` via
151
227
  * the crawler's defaults), so packaging a skill mid-run cannot invalidate it.
228
+ *
229
+ * 🚨 That exclusion is ALSO the cause of a live defect, and this paragraph is the
230
+ * reason the defect must not be fixed by widening the crawl. The packager's
231
+ * post-build lane sees an EMPTY link graph because the built tree it validates
232
+ * falls under `**\/dist\/**` — measured, and now pinned by a committed test; the
233
+ * numbers, the control that proves the cause, and the fixture all live in the
234
+ * `runPostBuildValidation` docstring in `../skill-packager.ts`, and are not
235
+ * restated here.
236
+ *
237
+ * ⛔ The exclusion is the stated safety argument DIRECTLY ABOVE, for a memo shared
238
+ * by the audit lane, the validate lane AND the built lane. Widening the crawl so
239
+ * one lane can see its own output retracts that argument for every lane sharing
240
+ * this memo — it is not a one-lane fix, and it lands here, not there.
241
+ */
242
+ const walkRegistryCache = new Map();
243
+ /**
244
+ * The same memo for the SOURCE-BACKED lanes, keyed first by the population
245
+ * source's IDENTITY and only then by root.
246
+ *
247
+ * ## Why the source is part of the key
248
+ *
249
+ * This crawl is memoized for a process and SHARED across commands that do not
250
+ * know about each other: `vat skills build` reaches it per skill through
251
+ * {@link validateSkillForPackaging}, `vat audit` reaches it directly, and the
252
+ * pipeline oracles reach it as a lane. Keyed on the root ALONE, the FIRST
253
+ * caller's population binds for the whole process, and a later caller asking a
254
+ * different question transparently receives the earlier caller's answer — with
255
+ * nothing in any output saying so. The projection lane does not merely enumerate
256
+ * the same set faster: it sees uncommitted markdown, so it genuinely answers a
257
+ * different membership question and ADDS `LINK_BROKEN_FILE` findings. Serving
258
+ * one caller's answer to the other is a silent wrong answer in both directions.
259
+ *
260
+ * ## Why IDENTITY, and not a lane descriptor
261
+ *
262
+ * A `'walk' | 'projection'` tag would key two projection sources the same, and
263
+ * two projection sources can differ in their ignore oracle (a run with no
264
+ * `GitTracker` admits the ignored half of a tree), in which store answers them,
265
+ * and in whether that store is still OPEN — `withResourcePopulationSource`
266
+ * closes it when its bracket ends. A key that cannot see those differences is
267
+ * the same bug one level down. The closure itself can never be wrong about
268
+ * which of them it is.
269
+ *
270
+ * The win survives: one run holds ONE source closure for its whole bracket and
271
+ * hands the same one to every skill, so a `vat skills build` still pays a single
272
+ * crawl for the run rather than one per skill. Two independent callers cannot
273
+ * hold the same closure by accident, so they cannot collide.
274
+ *
275
+ * A `WeakMap` rather than a `Map` because the entry's useful life is the
276
+ * bracket's: once the source is unreachable its store is closed and its
277
+ * registries can never be served again, so holding them would be a leak with no
278
+ * upside. (`registryCacheFor` is what reads it; `resetPackagingRegistryCache`
279
+ * replaces it wholesale, since a `WeakMap` cannot be cleared in place.)
152
280
  */
153
- const registryCache = new Map();
281
+ let sourcedRegistryCache = new WeakMap();
282
+ /**
283
+ * The (root → registry) memo this call belongs in: the walk's, or the one
284
+ * private to this population source.
285
+ *
286
+ * Separate maps rather than a composite key, because the two are keyed on
287
+ * different KINDS — a path string and an object identity — and a composite key
288
+ * would have to stringify the source, which is exactly the information loss
289
+ * {@link sourcedRegistryCache} exists to avoid.
290
+ */
291
+ function registryCacheFor(populationSource) {
292
+ if (populationSource === undefined) {
293
+ return walkRegistryCache;
294
+ }
295
+ const existing = sourcedRegistryCache.get(populationSource);
296
+ if (existing !== undefined) {
297
+ return existing;
298
+ }
299
+ const created = new Map();
300
+ sourcedRegistryCache.set(populationSource, created);
301
+ return created;
302
+ }
154
303
  /**
155
304
  * Drop every memoized registry.
156
305
  *
@@ -161,7 +310,34 @@ const registryCache = new Map();
161
310
  * as a fresh one. `resetAuditCaches` calls this alongside its own caches.
162
311
  */
163
312
  export function resetPackagingRegistryCache() {
164
- registryCache.clear();
313
+ walkRegistryCache.clear();
314
+ // Both lanes, or the reset is a half-truth: a caller that reset the cache and
315
+ // then re-supplied the SAME source closure would be served the pre-reset parse
316
+ // of a tree it may have changed. A `WeakMap` has no `clear`, so it is replaced.
317
+ sourcedRegistryCache = new WeakMap();
318
+ }
319
+ /**
320
+ * The registry a skill is validated against: the caller's, when it covers the
321
+ * skill; otherwise one crawled here under the caller's `unreadable` ruling.
322
+ *
323
+ * No shared context is a single-skill lane whose result is acted on whole (the
324
+ * packager's post-build check passes one; a library caller that passes nothing
325
+ * is the same shape): it refuses. A caller with a context has ruled on
326
+ * `shared.unreadable` — see that field.
327
+ *
328
+ * @param skillPath - Path to SKILL.md
329
+ * @param projectRoot - The root the fallback crawl covers
330
+ * @param shared - The batching caller's context, if any
331
+ * @returns A registry whose links are resolved and that covers `skillPath`
332
+ */
333
+ async function registryForSkill(skillPath, projectRoot, shared) {
334
+ if (shared?.registry !== undefined && registryCoversSkill(shared.registry, skillPath)) {
335
+ return shared.registry;
336
+ }
337
+ return crawlAndResolveRegistry(projectRoot, {
338
+ unreadable: shared === undefined ? 'refuse' : shared.unreadable,
339
+ ...(shared?.populationSource !== undefined && { populationSource: shared.populationSource }),
340
+ });
165
341
  }
166
342
  /**
167
343
  * True iff the shared registry's baseDir is an ancestor of (or equals) the
@@ -181,6 +357,34 @@ function registryCoversSkill(registry, skillPath) {
181
357
  // eslint-disable-next-line local/no-path-startswith -- both sides normalized via toForwardSlash above
182
358
  return skillDir.startsWith(`${normalizedBase}/`);
183
359
  }
360
+ /**
361
+ * The refusal for a matched file that has no frontmatter object — either no
362
+ * `---` block at all, or one whose YAML did not parse (`frontmatterError`).
363
+ *
364
+ * Either way the file is not a skill, and it is refused with the SAME
365
+ * non-overridable code `validateSkill` in skill-validator.ts emits for it:
366
+ * `SKILL_MISSING_FRONTMATTER` has no CODE_REGISTRY entry, so the framework's
367
+ * `finalize()` passes it through and `validation.severity` cannot name it.
368
+ *
369
+ * This refusal used to be absent from the packaging lane — its frontmatter
370
+ * checks ran only `if (parseResult.frontmatter)` — so a plain `# Readme` a
371
+ * `skills.include` glob drifted onto, or a SKILL.md that lost its fence, ran
372
+ * zero frontmatter checks and came back `success` under its H1 as a name:
373
+ * `vat skills validate`, `vat validate` and `vat verify` were green on a
374
+ * project that shipped no skill.
375
+ */
376
+ function missingFrontmatterIssue(frontmatterError, location) {
377
+ return {
378
+ severity: 'error',
379
+ code: 'SKILL_MISSING_FRONTMATTER',
380
+ message: frontmatterError === undefined
381
+ ? 'No YAML frontmatter found — a skill starts with a `---` block that names and describes it'
382
+ : `Frontmatter did not parse as YAML: ${frontmatterError}`,
383
+ location,
384
+ line: 1,
385
+ fix: 'Add YAML frontmatter with name and description fields, or narrow `skills.include` so it no longer matches this file',
386
+ };
387
+ }
184
388
  /**
185
389
  * Validate a skill for packaging
186
390
  *
@@ -201,7 +405,7 @@ export async function validateSkillForPackaging(skillPath, packagingConfig, cont
201
405
  // Validation-POLICY boundary (config root -> git root -> skill dir): what is
202
406
  // "outside the project", where `files:` sources resolve from, what the
203
407
  // registry crawls. Library fallback to skill dir keeps callers null-safe; CLI
204
- // command boundary owns any user-facing warning. See plan 2026-05-17.
408
+ // command boundary owns any user-facing warning.
205
409
  const projectRoot = findProjectRoot(dirname(skillPath)) ?? dirname(skillPath);
206
410
  // ANCHOR base — deliberately a separate variable from projectRoot above.
207
411
  // Every emitted location is relative to this ONE root, computed once before
@@ -210,14 +414,19 @@ export async function validateSkillForPackaging(skillPath, packagingConfig, cont
210
414
  const locationRoot = shared?.locationRoot ?? projectRoot;
211
415
  const skillLocation = issueLocation(skillPath, locationRoot);
212
416
  // Parse SKILL.md
213
- const parseResult = await parseMarkdown(skillPath);
214
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- skillPath is validated function parameter
215
- const skillContent = await readFile(skillPath, 'utf-8');
417
+ const parseResult = await parseFileCached(skillPath, 'markdown');
418
+ // The parser already decoded these bytes; a second whole-file read of the same
419
+ // path would only add a syscall and a TOCTOU window. `content` is the raw
420
+ // source verbatim, so fenced code blocks reach `runCompatDetectors` intact.
421
+ const skillContent = parseResult.content;
216
422
  const skillLines = skillContent.split('\n').length;
217
423
  // Validate frontmatter schema (name format, required fields, etc.)
218
424
  if (parseResult.frontmatter) {
219
425
  rawIssues.push(...validateFrontmatterSchema(parseResult.frontmatter, false, skillLocation), ...validateFrontmatterRules(parseResult.frontmatter, skillLocation));
220
426
  }
427
+ else {
428
+ rawIssues.push(missingFrontmatterIssue(parseResult.frontmatterError, skillLocation));
429
+ }
221
430
  // Compat capability detection: collect observations from SKILL.md and
222
431
  // surface each as a CAPABILITY_* issue. Observations are also returned
223
432
  // on the result so downstream verdict computation (CLI layer) can recover
@@ -240,21 +449,39 @@ export async function validateSkillForPackaging(skillPath, packagingConfig, cont
240
449
  // per-file markdown parse is paid for exactly once across the batch. Only
241
450
  // reuse when the shared registry covers this skill's projectRoot; otherwise
242
451
  // fall back to a fresh crawl to avoid leaking resources across roots.
243
- const registry = shared?.registry !== undefined && registryCoversSkill(shared.registry, skillPath)
244
- ? shared.registry
245
- : await crawlAndResolveRegistry(projectRoot);
452
+ const registry = await registryForSkill(skillPath, projectRoot, shared);
246
453
  const skillResource = registry.getResource(safePath.resolve(skillPath));
247
454
  // Only the entries the PACKAGER will actually copy defer a link: an entry pointing
248
455
  // into declared test input is dropped at build time, so its dest never appears and
249
456
  // a link to it is a genuine broken link — the same verdict `vat skills build`
250
- // reaches. See packagedFileEntries in test-input.ts.
457
+ // reaches. Same derivation `packagedFileEntries` performs for the lanes that have
458
+ // no second use for the dirs — see test-input.ts.
251
459
  //
252
460
  // `projectSkills` is the PROJECT's declared eval suites, assembled once by the
253
461
  // calling lane (see SkillValidationSharedContext.projectSkills). Empty when the
254
462
  // caller has no project to enumerate — a single-skill audit of a tree with no
255
463
  // config — which narrows this lane to the subject's own suite and nothing else.
256
464
  const projectSkills = shared?.projectSkills ?? [];
257
- const packagedFiles = packagedFileEntries(packagingConfig ?? {}, dirname(skillPath), projectRoot, projectSkills);
465
+ // ONE resolution per skill, shared by both consumers below (the `files:` filter and
466
+ // the walker's exclude rules), and ONE conventional-suite probe per RUN behind it.
467
+ //
468
+ // Two multipliers on the same filesystem question, both now closed. Resolving it
469
+ // once per CONSUMER doubled the term for no new information (the two call sites took
470
+ // identical arguments) — 6,844 probes on a 58-skill adopter declaring no `test:`
471
+ // block, of which 3,422 re-asked a question already answered. That fix removed the
472
+ // second call site and left the QUADRATIC standing: the resolution still probes the
473
+ // subject AND every entry in `projectSkills`, so a lane looping over S skills asked
474
+ // S^2 questions about S paths. Measured with the lab on a 103-skill adopter,
475
+ // `vat resources validate`: 10,815 probes over 103 distinct paths (~105 repeats
476
+ // each) — exactly 50% of that command's 21,648 user filesystem calls. With the run's
477
+ // probe threaded here it is ~103 probes and ~10,900 calls on the same tree.
478
+ //
479
+ // A caller that omits `suiteProbe` is claiming this call IS the run (see the field's
480
+ // docstring); the narrowing is written here, at the call site, so it cannot happen by
481
+ // omission inside `resolveTestInputDirs`.
482
+ const suiteProbe = shared?.suiteProbe ?? conventionalSuiteProbe();
483
+ const testInputDirs = resolveTestInputDirs(packagingConfig ?? {}, dirname(skillPath), projectSkills, suiteProbe);
484
+ const packagedFiles = partitionTestInputFileEntries(packagingConfig?.files ?? [], projectRoot, testInputDirs).kept;
258
485
  const deferred = DeferredArtifacts.from([{ files: packagedFiles, skillDir: dirname(skillPath) }], projectRoot);
259
486
  // What the `files:` GLOBs will do to this build, reported HERE — before any
260
487
  // build — because this is where it is still cheap to act on: `vat skills
@@ -270,7 +497,6 @@ export async function validateSkillForPackaging(skillPath, packagingConfig, cont
270
497
  // while staying silent about the fatal zero-match was the asymmetry that
271
498
  // let `vat skills validate` return success on a config that cannot build.
272
499
  rawIssues.push(...preBuildGlobFindingsToIssues(await collectPreBuildGlobFindings(packagedFiles, projectRoot), locationRoot));
273
- const testInputDirs = resolveTestInputDirs(packagingConfig ?? {}, dirname(skillPath), projectSkills);
274
500
  const walkOptions = {
275
501
  maxDepth,
276
502
  // The SAME test-input exclusion the packager applies. Without it this lane
@@ -289,20 +515,20 @@ export async function validateSkillForPackaging(skillPath, packagingConfig, cont
289
515
  if (shared?.gitTracker !== undefined) {
290
516
  walkOptions.gitTracker = shared.gitTracker;
291
517
  }
292
- const { bundledResources, bundledAssets, excludedReferences, maxBundledDepth, deferredAssets } = walkLinkGraph(skillResource?.id ?? '', registry, walkOptions);
518
+ const { bundledResources, bundledAssets, excludedReferences, outsideSkillDirLinks, maxBundledDepth, deferredAssets } = walkLinkGraph(skillResource?.id ?? '', registry, walkOptions);
293
519
  const bundledFiles = [...bundledResources.map(r => r.filePath), ...bundledAssets];
294
520
  // Count direct links that actually made it into the bundle
295
521
  const directLinks = getResolvedMarkdownLinks(parseResult.links, skillPath);
296
522
  const bundledFileSet = new Set(bundledFiles);
297
523
  const directFileCount = directLinks.filter(p => bundledFileSet.has(p)).length;
298
- // Three producers, one append, order significant. Each is a receipt for a link
299
- // the walker dropped or deferred, and all three anchor on `locationRoot`:
524
+ // Four producers, one append, order significant, all anchored on `locationRoot`:
300
525
  // 1. walker exclusions (LINK_OUTSIDE_PROJECT, LINK_TARGETS_DIRECTORY, etc.)
301
526
  // 2. links dropped for pointing into declared test input — the same issue, at
302
527
  // the same location, the packager emits for it. `projectRoot` scopes WHICH
303
528
  // dirs count as declared test input; `locationRoot` only anchors.
304
529
  // 3. one info issue per deferred asset declared in files: config
305
- rawIssues.push(...walkerExclusionsToIssues(excludedReferences, locationRoot), ...testInputLinkIssues(excludedReferences, testInputDirs, projectRoot, locationRoot), ...deferredAssetsToIssues(deferredAssets, locationRoot));
530
+ // 4. followed links bundled from outside the skill directory (LINK_OUTSIDE_SKILL_DIR)
531
+ rawIssues.push(...walkerExclusionsToIssues(excludedReferences, locationRoot), ...testInputLinkIssues(excludedReferences, testInputDirs, projectRoot, locationRoot), ...deferredAssetsToIssues(deferredAssets, locationRoot), ...outsideSkillDirLinksToIssues(outsideSkillDirLinks, locationRoot));
306
532
  const fileCount = bundledFiles.length + 1; // +1 for SKILL.md itself
307
533
  const maxLinkDepth = maxBundledDepth;
308
534
  // Calculate total lines from bundled markdown files only, and scan each
@@ -311,13 +537,20 @@ export async function validateSkillForPackaging(skillPath, packagingConfig, cont
311
537
  let totalLines = skillLines;
312
538
  for (const bundledFile of bundledFiles) {
313
539
  if (bundledFile.endsWith('.md')) {
314
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- bundledFile resolved from markdown parser
315
540
  const content = await readFile(bundledFile, 'utf-8');
316
541
  totalLines += content.split('\n').length;
317
542
  // Anchor contract: never hand a producer an absolute path as `location`.
318
543
  const bundledLocation = issueLocation(bundledFile, locationRoot);
319
544
  collectNonPortableAssetReferenceIssues(content, bundledLocation, rawIssues);
320
545
  collectNonPortableCommandIssues(content, bundledLocation, rawIssues);
546
+ // Whole-file bytes, frontmatter included — safe because the detector
547
+ // strips leading frontmatter itself. It did not always, and this lane was
548
+ // the half of the invariant nobody was enforcing: a bundled file carrying
549
+ // `allowed-tools: [mcp__x__do_thing]` seeded the vocabulary the SKILL.md
550
+ // lane's comment (below) says must come from prose, then fired on its own
551
+ // body. Pinned by "does not let a bundled file's allowed-tools frontmatter
552
+ // supply the vocabulary" in packaging-validator.test.ts.
553
+ collectUnqualifiedMcpToolIssues(content, bundledLocation, rawIssues);
321
554
  }
322
555
  }
323
556
  const excludedDetails = deduplicateExcludedReferences(excludedReferences, skillPath);
@@ -329,8 +562,19 @@ export async function validateSkillForPackaging(skillPath, packagingConfig, cont
329
562
  collectTimeSensitiveContentIssues(parseResult.content, skillLocation, rawIssues);
330
563
  collectNonPortableAssetReferenceIssues(parseResult.content, skillLocation, rawIssues);
331
564
  collectNonPortableCommandIssues(parseResult.content, skillLocation, rawIssues);
565
+ // An `allowed-tools:` list of `mcp__…` names does not by itself supply the
566
+ // vocabulary: the qualified spelling has to appear in the prose an agent
567
+ // reads, or "the document contradicts itself" is not the finding. That holds
568
+ // because the DETECTOR strips frontmatter. ⛔ It was NEVER true that this call
569
+ // site passed a post-frontmatter slice — `parseResult.content` is the raw file
570
+ // verbatim, as the comment 190 lines above says — so before the detector
571
+ // stripped, this lane did read `allowed-tools:` as prose and did ship the
572
+ // findings that came of it.
573
+ collectUnqualifiedMcpToolIssues(parseResult.content, skillLocation, rawIssues);
332
574
  // Cross-skill dependency smell: body declares a requires/depends token the
333
- // description does not mention. Uses the post-frontmatter content slice.
575
+ // description does not mention. Reads the RAW file, frontmatter included —
576
+ // which is what this detector wants, since the declaration it looks for can be
577
+ // written either side of the fence.
334
578
  if (parseResult.frontmatter) {
335
579
  rawIssues.push(...detectUndeclaredCrossSkillAuth(parseResult.frontmatter, parseResult.content, skillLocation));
336
580
  }
@@ -510,9 +754,23 @@ const RELATIVE_PATH_HINT = 'Reference bundled files by a path relative to the sk
510
754
  * plain form; falling to (2) is what still flags the operator forms — a
511
755
  * lone `\$\{NAME\}` alternative silently missed `${NAME:-default}`, an
512
756
  * idiomatic non-portable reference.
757
+ * 3. `\$\{?env:NAME\b` — PowerShell's expansion, BOTH spellings. A skill that
758
+ * documents a Windows invocation writes `$env:CLAUDE_SKILL_DIR` or
759
+ * `${env:CLAUDE_SKILL_DIR}`, neither of which the two POSIX alternatives
760
+ * match: after `$`/`${` comes `env:`, not the variable name. The forms are
761
+ * equally non-portable, so leaving them out flagged the bash line of a skill
762
+ * and waved through the PowerShell line directly beneath it.
763
+ *
764
+ * ⚠️ Compiled with the `i` flag, and that is about alternative 3 specifically.
765
+ * PowerShell variable and drive names are case-INSENSITIVE, so `$Env:NAME` and
766
+ * `$ENV:NAME` are as valid — and as commonly authored — as `$env:NAME`. A
767
+ * case-SENSITIVE `\$env:` matched one of the three and silently passed the other
768
+ * two, reintroducing exactly the asymmetry this alternative was added to remove.
769
+ * The variable names themselves are uppercase literals passed in by the call
770
+ * sites below, so case-insensitivity costs nothing there.
513
771
  */
514
772
  // eslint-disable-next-line security/detect-non-literal-regexp -- composed from a compile-time constant name, no user input
515
- const envVarPattern = (name) => new RegExp(String.raw `\$\{${name}\}|\$\{?${name}\b`);
773
+ const envVarPattern = (name) => new RegExp(String.raw `\$\{${name}\}|\$\{?env:${name}\b|\$\{?${name}\b`, 'i');
516
774
  const NON_PORTABLE_ASSET_VARIANTS = [
517
775
  {
518
776
  label: 'claude-plugin-root',
@@ -530,6 +788,40 @@ const NON_PORTABLE_ASSET_VARIANTS = [
530
788
  pattern: envVarPattern('CLAUDE_PROJECT_DIR'),
531
789
  fix: '`CLAUDE_PROJECT_DIR` is a Claude Code-only variable, so a skill relying on it will not resolve the user\'s project on other runtimes. There is no skill-relative equivalent — if the skill genuinely operates on the user\'s repository, take the location as an explicit parameter with `$CLAUDE_PROJECT_DIR` as a fallback, and make sure the skill\'s declared `targets` reflect the Claude Code dependency.',
532
790
  },
791
+ {
792
+ // Load-bearing in Claude Code and MEANINGLESS in the API code-execution
793
+ // container. Both facts are true for good reasons, which is why this is a
794
+ // warning and not a rename: in Claude Code a skill may live under
795
+ // `~/.claude/skills/`, a plugin tree, a project dir or a marketplace install
796
+ // path while cwd is the user's repo, and the consumer needing a real path is
797
+ // often a PROCESS (a script argument) that does not share the model's
798
+ // context — there is no literal an author can write. The API container has
799
+ // the opposite property: the location is a stable literal (`/skills/<name>/`,
800
+ // cwd `/`) so nothing is parameterised and no equivalent variable is set,
801
+ // measured live against the Messages API. `${CLAUDE_SKILL_DIR}` therefore
802
+ // expands to empty there and `node "/scripts/x.mjs"` does not exist.
803
+ label: 'claude-skill-dir',
804
+ pattern: envVarPattern('CLAUDE_SKILL_DIR'),
805
+ fix: `\`CLAUDE_SKILL_DIR\` is set by Claude Code and is absent in the API code-execution container, where it expands to empty. ${RELATIVE_PATH_HINT} A relative path resolves on every target because the MODEL resolves it against the skill directory — not because cwd happens to be right. When a process genuinely needs an absolute path, instruct the agent to cd into the skill directory first rather than reaching for another variable.`,
806
+ },
807
+ {
808
+ // The same mistake pointed the other way: the API container's literal mount
809
+ // point hardcoded into a skill, which resolves nowhere in Claude Code. Anchored
810
+ // at a path boundary so `~/.claude/skills/x/` and `.claude/skills/` in prose
811
+ // (both preceded by a word character) are not flagged.
812
+ //
813
+ // ⚠️ The TRAILING separator is optional, and that is the common spelling —
814
+ // not an edge case. `/skills/<name>` with nothing after it is the mount
815
+ // POINT, which is what the sibling `claude-skill-dir` fix text tells authors
816
+ // to reach for ("cd into the skill directory first"). Requiring the trailing
817
+ // `/` made `cd /skills/my-skill && node scripts/run.mjs` invisible while
818
+ // firing on `node /skills/my-skill/scripts/run.mjs` on the next line. The
819
+ // `(?![\w.-])` branch keeps the name from being truncated mid-token, so a
820
+ // longer name is still matched whole rather than at a prefix.
821
+ label: 'api-skill-mount',
822
+ pattern: /(?:^|[\s"'`(])\/skills\/[A-Za-z0-9._-]+(?:\/|(?![\w.-]))/,
823
+ fix: `\`/skills/<name>/\` is the Anthropic API code-execution container's mount point and does not exist in Claude Code, a claude.ai upload, or any other host. ${RELATIVE_PATH_HINT} When a process genuinely needs an absolute path, instruct the agent to cd into the skill directory first.`,
824
+ },
533
825
  {
534
826
  label: 'absolute-script-path',
535
827
  pattern: /\b(?:node|bun|deno|python3?|ruby|sh|bash|uv)\s+["']?\/[^\s"']+\.(?:mjs|cjs|js|ts|py|rb|sh)\b/,
@@ -656,7 +948,7 @@ function collectPortabilityFamilyIssues(content, docLocation, issues, family) {
656
948
  * Collect NON_PORTABLE_ASSET_REFERENCE issues — scan one skill document for any
657
949
  * member of the non-portable asset-reference family.
658
950
  */
659
- function collectNonPortableAssetReferenceIssues(content, docLocation, issues) {
951
+ export function collectNonPortableAssetReferenceIssues(content, docLocation, issues) {
660
952
  collectPortabilityFamilyIssues(content, docLocation, issues, {
661
953
  code: 'NON_PORTABLE_ASSET_REFERENCE',
662
954
  variants: NON_PORTABLE_ASSET_VARIANTS,
@@ -670,7 +962,7 @@ function collectNonPortableAssetReferenceIssues(content, docLocation, issues) {
670
962
  * Collect NON_PORTABLE_COMMAND issues — scan one skill document for any member of
671
963
  * the non-portable shell-command family.
672
964
  */
673
- function collectNonPortableCommandIssues(content, docLocation, issues) {
965
+ export function collectNonPortableCommandIssues(content, docLocation, issues) {
674
966
  collectPortabilityFamilyIssues(content, docLocation, issues, {
675
967
  code: 'NON_PORTABLE_COMMAND',
676
968
  variants: NON_PORTABLE_COMMAND_VARIANTS,
@@ -689,10 +981,24 @@ function collectProgressiveDisclosureIssue(skillLines, referenceFileCount, skill
689
981
  }
690
982
  }
691
983
  /**
692
- * Process links from parsed markdown and return resolved .md file paths
984
+ * Process links from parsed markdown and return resolved .md file paths.
985
+ *
986
+ * Exported for unit test: the deduplication contract (one entry, and one existence
987
+ * probe, per DISTINCT target however many times it is linked) is not observable
988
+ * through `validateSkillForPackaging`'s result alone.
693
989
  */
694
- function getResolvedMarkdownLinks(links, markdownPath) {
695
- const resolvedPaths = [];
990
+ export function getResolvedMarkdownLinks(links, markdownPath) {
991
+ const markdownDir = dirname(markdownPath);
992
+ // Collapse to DISTINCT targets before touching the filesystem. `links` is one
993
+ // entry per link OCCURRENCE, and documents routinely point many occurrences at
994
+ // one file (a routing table linking every row to the same sub-skill). Two
995
+ // consequences, both fixed here rather than downstream:
996
+ // - the caller derives `directFileCount` from this list, so occurrences made
997
+ // it report more direct files than the whole bundle holds;
998
+ // - the existence probe below ran once per occurrence. On this repo's own
999
+ // cat-agents skill that was 42 probes over 9 distinct paths.
1000
+ // A Set preserves insertion order, so the surviving order is unchanged.
1001
+ const candidates = new Set();
696
1002
  for (const link of links) {
697
1003
  if (link.type !== 'local_file') {
698
1004
  continue;
@@ -702,22 +1008,24 @@ function getResolvedMarkdownLinks(links, markdownPath) {
702
1008
  if (hrefWithoutAnchor === '') {
703
1009
  continue;
704
1010
  }
705
- // Resolve path
706
- const resolvedPath = safePath.resolve(dirname(markdownPath), hrefWithoutAnchor);
707
- // Only include .md files that exist
708
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- Path constructed from parsed markdown
709
- if (!resolvedPath.endsWith('.md') || !existsSync(resolvedPath)) {
710
- continue;
1011
+ const resolvedPath = safePath.resolve(markdownDir, hrefWithoutAnchor);
1012
+ // Only .md targets are candidates; existence is settled once, below.
1013
+ if (resolvedPath.endsWith('.md')) {
1014
+ candidates.add(resolvedPath);
711
1015
  }
712
- resolvedPaths.push(resolvedPath);
713
1016
  }
714
- return resolvedPaths;
1017
+ return [...candidates].filter(candidate => existsSync(candidate));
715
1018
  }
716
1019
  /**
717
1020
  * Reasons that are reported as validation errors instead of excluded references.
718
1021
  * These are filtered out of the excluded references list.
719
1022
  */
720
- const VALIDATION_ERROR_REASONS = new Set([EXCLUDE_REASON_DIRECTORY, EXCLUDE_REASON_OUTSIDE_PROJECT]);
1023
+ const EXCLUDE_REASON_UNREADABLE = 'unreadable-target';
1024
+ const VALIDATION_ERROR_REASONS = new Set([
1025
+ EXCLUDE_REASON_DIRECTORY,
1026
+ EXCLUDE_REASON_OUTSIDE_PROJECT,
1027
+ EXCLUDE_REASON_UNREADABLE,
1028
+ ]);
721
1029
  /**
722
1030
  * Deduplicate excluded references by path, preserving detail from first occurrence.
723
1031
  * Filters out entries reported as validation errors (directory-target, outside-project).
@@ -751,9 +1059,15 @@ function mapExcludeReason(excludeReason) {
751
1059
  case 'agent-instruction-file': return 'agent-instruction-file';
752
1060
  case 'skill-definition': return 'skill-definition';
753
1061
  case 'gitignored': return 'gitignored';
1062
+ // Reported under its own name rather than falling into the default arm.
1063
+ // The default answers `depth-exceeded`, which for this reason would be
1064
+ // false: the walk never reached the edge at all, because the file holding
1065
+ // it is not one VAT routes through.
1066
+ case 'non-routable-source': return 'non-routable-source';
754
1067
  case 'depth-exceeded':
755
1068
  case EXCLUDE_REASON_DIRECTORY:
756
1069
  case EXCLUDE_REASON_OUTSIDE_PROJECT:
1070
+ case EXCLUDE_REASON_UNREADABLE:
757
1071
  case 'missing-target':
758
1072
  case undefined:
759
1073
  default:
@@ -769,9 +1083,11 @@ function extractSkillName(parseResult, skillPath) {
769
1083
  if (frontmatterName && typeof frontmatterName === 'string') {
770
1084
  return frontmatterName;
771
1085
  }
772
- // Try H1 title (use [^\n] instead of .+ to avoid backtracking)
773
- // eslint-disable-next-line sonarjs/slow-regex -- Using [^\n]+ instead of .+ to avoid backtracking
774
- const h1Match = /^#\s+([^\n]+)$/m.exec(parseResult.content);
1086
+ // Try H1 title. `[ \t]` is a single fixed-width class, not a quantifier, so it
1087
+ // cannot compete with the `[^\n]*` capture for the same space — that ambiguity
1088
+ // is what made the old `\s+([^\n]+)` form backtrack super-linearly. Trailing
1089
+ // whitespace is removed by the .trim() below, exactly as before.
1090
+ const h1Match = /^#[ \t]([^\n]*)$/m.exec(parseResult.content);
775
1091
  if (h1Match?.[1]) {
776
1092
  return h1Match[1].trim();
777
1093
  }