@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
@@ -8,11 +8,34 @@
8
8
  * Non-markdown assets (images, JSON, etc.) won't be in the registry and still
9
9
  * require `existsSync()` checks — this is acceptable since the goal is eliminating
10
10
  * redundant I/O for markdown files that are already parsed.
11
+ *
12
+ * ## Membership is not traversability
13
+ *
14
+ * A registry MEMBER is a file VAT has parsed: its links are known, so the
15
+ * packager's rewriter can reach inside it and repoint them at bundled copies. A
16
+ * ROUTABLE file is one the walker walks THROUGH — it enqueues that file's link
17
+ * targets as further bundle members. Routing is markdown-only
18
+ * ({@link isRoutable}); HTML is "a leaf we can read, not a door we walk
19
+ * through".
20
+ *
21
+ * These used to be the same thing, by accident: any link target that
22
+ * `getResourceById` found was queued, so a registry's include globs silently
23
+ * WERE the routability policy. The two production registries disagreed as a
24
+ * result. `crawlAndResolveRegistry` includes HTML, so `vat audit` and
25
+ * post-build validation walked THROUGH a bundled page and counted its targets
26
+ * as bundled; the packager's `createProjectRegistry` is markdown-only, so at
27
+ * walk time the same page was an opaque asset and its targets were dropped.
28
+ * Same tree, two answers to "is HTML a door" — and audit's answer described a
29
+ * bundle the build never produced.
30
+ *
31
+ * Links out of a non-routable member are not silently dropped: each one whose
32
+ * target the walk never bundled by some other route is reported as a
33
+ * `non-routable-source` exclusion (`LINK_FROM_NON_ROUTABLE_FILE`).
11
34
  */
12
- import { existsSync, statSync } from 'node:fs';
13
35
  import { basename, dirname } from 'node:path';
14
- import { isLocalFileLink, resolveLocalHref } from '@vibe-agent-toolkit/resources';
15
- import { isGitIgnored, toForwardSlash, safePath } from '@vibe-agent-toolkit/utils';
36
+ import { isLocalFileLink, parserKindForPath, resolveLocalHref, } from '@vibe-agent-toolkit/resources';
37
+ import { CRAWL_PASS_INSIDE, CRAWL_WALKER_GITIGNORE_ID, CRAWL_WALKER_ID, crawlTimingStart, FsLookupCache, recordCrawlPass, relativeEscapesRoot, toForwardSlash, safePath, } from '@vibe-agent-toolkit/utils';
38
+ import { isGitIgnored } from '@vibe-agent-toolkit/utils/git';
16
39
  import picomatch from 'picomatch';
17
40
  import { isAgentInstructionBasename, isNavigationBasename } from './validators/validation-rules.js';
18
41
  // ============================================================================
@@ -48,36 +71,55 @@ function resolveHrefToPath(hrefWithoutAnchor, sourceFilePath, projectRoot) {
48
71
  }
49
72
  /** Check if a link targets a file outside the project boundary */
50
73
  function isOutsideProject(targetPath, projectRoot) {
51
- return safePath.relative(projectRoot, targetPath).startsWith('..');
74
+ return relativeEscapesRoot(safePath.relative(projectRoot, targetPath));
52
75
  }
53
76
  // Navigation / agent-instruction classification is the shared, case-insensitive
54
77
  // matcher from validation-rules (`isNavigationBasename` / `isAgentInstructionBasename`),
55
78
  // used directly at the call sites below. On APFS/NTFS a `Claude.md` is loaded as
56
79
  // instructions exactly as a `CLAUDE.md` is, so the walker must refuse both spellings.
57
- /** Create an exclusion record */
58
- function makeExclusion(targetPath, sourcePath, targetExists, reason, link, matchedRule) {
80
+ function makeFollowedLink(targetPath, sourcePath, targetExists, link) {
59
81
  return {
60
82
  path: targetPath,
61
83
  sourcePath,
62
84
  ...(link.line !== undefined && { sourceLine: link.line }),
63
85
  targetExists,
86
+ bundled: true,
87
+ linkText: link.text,
88
+ linkHref: link.href,
89
+ };
90
+ }
91
+ /** Create an exclusion record */
92
+ function makeExclusion(targetPath, sourcePath, targetExists, reason, link, matchedRule) {
93
+ return {
94
+ ...makeFollowedLink(targetPath, sourcePath, targetExists, link),
64
95
  bundled: false,
65
96
  excludeReason: reason,
66
97
  ...(matchedRule ? { matchedRule } : {}),
67
- linkText: link.text,
68
- linkHref: link.href,
69
98
  };
70
99
  }
71
100
  /**
72
- * Check if a target path is a not-yet-materialized deferred build artifact, and
73
- * record it in the deferred set if so. Must run BEFORE any statSync / directory
74
- * check to avoid blowing up on missing paths.
101
+ * Build the pass-1′ row for one link target.
102
+ *
103
+ * The two syscalls come from {@link FsLookupCache.probe}, which memoizes them
104
+ * across links, so repeated calls for the same path are free of I/O.
105
+ */
106
+ function fillPathFacts(targetPath, options, state) {
107
+ const { exists, isDirectory } = state.pathProbe.probe(targetPath);
108
+ return {
109
+ exists,
110
+ isDirectory,
111
+ insideProject: !isOutsideProject(targetPath, options.projectRoot),
112
+ };
113
+ }
114
+ /**
115
+ * Check if a target path is a not-yet-materialized deferred build artifact.
116
+ * Must run BEFORE any directory check to avoid classifying a missing path.
75
117
  *
76
118
  * This check is gated on the target NOT existing on disk: it classifies the
77
119
  * "hasn't been built yet" half of the deferred-artifact lifecycle. An
78
120
  * existing real file at a covered files: dest (or source) is NOT deferred by
79
121
  * THIS check — it falls through to the directory-target check and beyond.
80
- * The gitignore branch further down in {@link checkExclusions} carries its
122
+ * The gitignore branch further down ({@link classifyGitignored}) carries its
81
123
  * OWN, unconditional `deferredArtifacts` exemption (existing or not) for the
82
124
  * "already built, and gitignored as expected" half — see the comment there.
83
125
  * So an existing-but-gitignored covered path still ends up classified as
@@ -85,17 +127,12 @@ function makeExclusion(targetPath, sourcePath, targetExists, reason, link, match
85
127
  * existing-and-NOT-gitignored covered path (or an uncovered path) reaches
86
128
  * normal handling. {@link DeferredArtifacts.covers} does the exact-OR-
87
129
  * directory-prefix membership test (pure, no filesystem access); the
88
- * existence answer is supplied by the caller, which probes the target once
89
- * for the whole classifier.
130
+ * existence answer comes from the target's pass-1′ row.
90
131
  *
91
132
  * @returns true if the path was classified as deferred
92
133
  */
93
- function checkDeferred(targetPath, targetExists, deferredArtifacts, deferredAssetSet) {
94
- if (deferredArtifacts.covers(targetPath) && !targetExists) {
95
- deferredAssetSet.add(toForwardSlash(targetPath));
96
- return true;
97
- }
98
- return false;
134
+ function checkDeferred(targetPath, targetExists, deferredArtifacts) {
135
+ return deferredArtifacts.covers(targetPath) && !targetExists;
99
136
  }
100
137
  /**
101
138
  * Does the walker refuse to bundle this target because it is a repo-internal
@@ -141,8 +178,8 @@ function refusesAgentInstructionFile(targetPath, options) {
141
178
  return !declaredSources.has(toForwardSlash(safePath.relative(options.projectRoot, targetPath)));
142
179
  }
143
180
  /**
144
- * Record a gitignored target as either a leak or an exempted `files:` build
145
- * artifact — the caller always excludes it from the walk either way; only
181
+ * Classify a target already known to be gitignored as either a leak or an
182
+ * exempted `files:` build artifact — the walk excludes it either way; only
146
183
  * the record differs.
147
184
  *
148
185
  * A files:-declared DEST is exempt from the leak rule EVEN WHEN it already
@@ -161,18 +198,13 @@ function refusesAgentInstructionFile(targetPath, options) {
161
198
  * coverage must NOT exempt it. An uncovered path, or a path covered only via
162
199
  * `sourcePaths`, still reports the leak normally.
163
200
  */
164
- function recordGitignoredTarget(targetPath, sourcePath, link, deferredArtifacts, excludedReferences, deferredAssetSet) {
165
- if (deferredArtifacts?.coversDest(targetPath)) {
166
- deferredAssetSet.add(toForwardSlash(targetPath));
167
- return;
168
- }
169
- // `targetExists: true` is not an assumption here — the caller only reaches
170
- // this function for a target it has already stat'ed and found present.
171
- excludedReferences.push(makeExclusion(targetPath, sourcePath, true, 'gitignored', link));
201
+ function classifyGitignoredTarget(targetPath, deferredArtifacts) {
202
+ return deferredArtifacts?.coversDest(targetPath)
203
+ ? { kind: 'deferred' }
204
+ : { kind: 'excluded', reason: 'gitignored' };
172
205
  }
173
206
  /**
174
- * The gitignore branch of {@link checkExclusions}, extracted to keep that
175
- * classifier within its cognitive-complexity budget.
207
+ * The `files:`-dest exemption for a target already known to be gitignored.
176
208
  *
177
209
  * GATED ON EXISTENCE, and the gate is the point. A path that is not there
178
210
  * cannot leak anything, and neither ignore oracle can be trusted to say so:
@@ -187,69 +219,125 @@ function recordGitignoredTarget(targetPath, sourcePath, link, deferredArtifacts,
187
219
  * Prefer the pre-populated GitTracker when the caller plumbed one through: it
188
220
  * answers in O(1) against the active set, no `git check-ignore` spawn.
189
221
  *
190
- * @returns true if the target was recorded (as a leak, or as an exempted
191
- * `files:` build artifact) and the caller should stop classifying
222
+ * **Memoized per walk**, so a target named by many documents costs one answer
223
+ * rather than one per reference. The memo cannot change the answer: both
224
+ * oracles are pure functions of the tree, and the memo's lifetime is one walk.
225
+ *
226
+ * ⛔ **Measured on VAT's own tree, and the result is a NEGATIVE —
227
+ * do not sell this as a performance win.** Counting asks vs. memo misses in the
228
+ * built lane: `vat audit .` = 3 asks / 2 distinct, `vat skills build` = 0 / 0,
229
+ * `vat inventory .` = 0 / 0. **One** oracle call avoided across all three.
230
+ *
231
+ * The reason is structural, not incidental: this branch is LAST in the cascade,
232
+ * so a target only reaches it after surviving all seven earlier discriminators
233
+ * *and* existing on disk. Nearly every link target is classified before then —
234
+ * bundled markdown, a directory, a navigation file, a pattern match. The memo's
235
+ * real justification is therefore **shape, not speed**: it is what lets the
236
+ * oracle read sit outside {@link classifyExclusion} so that function can be a
237
+ * pure predicate over the row. Any future claim of a spawn-count win needs its
238
+ * own measurement on a corpus where this branch is actually hot.
239
+ *
240
+ * Two further bounds, so nobody reads more into it than it does:
241
+ *
242
+ * - **The collapse is within-ONE-walk fan-in only.** `collectLinkedFiles` calls
243
+ * {@link walkLinkGraph} once per skill, so a target shared across skills is
244
+ * still asked about once per skill. That per-walk lifetime is exactly what
245
+ * makes the memo safe against `packageSkill` writing `dist/` between skills,
246
+ * so it is a deliberate ceiling, not a missed optimization.
247
+ * - **Not every repeat was a subprocess.** {@link isGitIgnored} settles "is
248
+ * there a repository here?" from the filesystem and returns early when there
249
+ * is none, so on a tree with no `.git` ancestor the repeats were ancestor
250
+ * walks rather than `git check-ignore` spawns. Inside a real repository they
251
+ * were spawns.
252
+ *
253
+ * @returns whether the target is gitignored
192
254
  */
193
- function checkGitignored(targetPath, sourcePath, targetExists, link, options, excludedReferences, deferredAssetSet) {
194
- if (!targetExists) {
195
- return false;
196
- }
255
+ function readGitignored(targetPath, options, state) {
256
+ const cached = state.gitignoreFacts.get(targetPath);
257
+ if (cached !== undefined)
258
+ return cached;
259
+ // Charged on the MISS path only, so `calls` counts oracle READS rather than
260
+ // asks — a memo hit costs nothing worth a bracket, and counting it would make
261
+ // the row's ms/call meaningless. This is the branch that can spawn
262
+ // `git check-ignore`, which is why it is separated from the walk's own row at
263
+ // all: the walk's cost and the oracle's cost move for different reasons.
264
+ const startedAt = crawlTimingStart();
197
265
  const isIgnored = options.gitTracker === undefined
198
266
  ? isGitIgnored(targetPath, options.projectRoot)
199
267
  : options.gitTracker.isIgnoredByActiveSet(targetPath);
200
- if (!isIgnored) {
201
- return false;
202
- }
203
- recordGitignoredTarget(targetPath, sourcePath, link, options.deferredArtifacts, excludedReferences, deferredAssetSet);
204
- return true;
268
+ recordCrawlPass(CRAWL_WALKER_GITIGNORE_ID, 'crawl', CRAWL_PASS_INSIDE, startedAt);
269
+ state.gitignoreFacts.set(targetPath, isIgnored);
270
+ return isIgnored;
205
271
  }
206
272
  /**
207
- * Check if a target path should be excluded for structural reasons
208
- * (deferred build artifact, directory, outside project, navigation file, or
209
- * pattern match).
273
+ * The `exists` × `isDirectory` corner of the cascade — the only place the row's
274
+ * three-state `isDirectory` is read.
210
275
  *
211
- * @returns true if the link was excluded or deferred (caller should skip to next link)
276
+ * `null` means *no answer about the kind*: the path is present to `existsSync`
277
+ * and unstattable anyway (a permissions problem, or a change racing the two
278
+ * calls). The classifier can say nothing about WHAT the target is — but it can
279
+ * say that the target is there and could not be read, and that is the report.
280
+ *
281
+ * ✅ **This used to skip silently, and that was D6 in the spec's deferred-fixes
282
+ * ledger — now fixed.** `{ kind: 'skipped' }` recorded nothing at all, so an
283
+ * unreadable link target vanished from the report entirely: no exclusion, no
284
+ * bundling, no issue. It survived the pass-1′ refactor only because that
285
+ * refactor was held to byte-identical output (it is what the pre-refactor
286
+ * `catch { return true }` did). Meanwhile the resources lane turned the
287
+ * IDENTICAL class of read failure into a `RESOURCE_UNREADABLE` finding, so one
288
+ * unreadable file got two answers depending on which lane found it. It is now
289
+ * an `unreadable-target` exclusion, which the verdict engine reports as
290
+ * `LINK_TARGET_UNREADABLE` — the resources code's skill-packaging sibling.
291
+ *
292
+ * @returns a verdict when the path's kind decides the outcome, else `undefined`
212
293
  */
213
- function checkExclusions(targetPath, sourcePath, link, options, excludeMatchers, excludedReferences, deferredAssetSet) {
214
- // ONE existence probe for the whole classifier. Several branches below are
215
- // meaningful only for a target that is actually there, and one of them
216
- // (gitignore) is actively WRONG without it — see the comment on that branch.
217
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- Path from parsed markdown
218
- const targetExists = existsSync(targetPath);
294
+ function classifyPathKind(facts) {
295
+ if (!facts.exists)
296
+ return undefined;
297
+ if (facts.isDirectory === true)
298
+ return { kind: 'excluded', reason: 'directory-target' };
299
+ if (facts.isDirectory === null)
300
+ return { kind: 'excluded', reason: 'unreadable-target' };
301
+ return undefined;
302
+ }
303
+ /**
304
+ * The first-match-wins exclusion cascade, as a **pure function of the pass-1′
305
+ * row**. No filesystem access, no walk-state mutation, no recording.
306
+ *
307
+ * ⚠️ **The order IS the behaviour.** This is a cascade, not a set of
308
+ * independent predicates: a directory that is also pattern-matched is reported
309
+ * as `directory-target`, and rewriting it as `reasons.find(...)` over
310
+ * independently-evaluated conditions would silently repick which reason wins.
311
+ * Every branch below sits exactly where it sat when the classifier did its own
312
+ * I/O inline; only the source of the facts changed.
313
+ *
314
+ * The one branch that is not a column is the last one — see
315
+ * {@link readGitignored} for why the gitignore oracle is demand-read at the
316
+ * point the cascade reaches it rather than filled for every row.
317
+ *
318
+ * @returns the verdict, or `undefined` when nothing excludes the target
319
+ */
320
+ function classifyExclusion(targetPath, options, excludeMatchers, facts) {
219
321
  // The deferred check is the FIRST discriminator: a not-yet-materialized target
220
322
  // must be classified before any check that would read it off disk.
221
- if (options.deferredArtifacts && checkDeferred(targetPath, targetExists, options.deferredArtifacts, deferredAssetSet)) {
222
- return true;
323
+ if (options.deferredArtifacts && checkDeferred(targetPath, facts.exists, options.deferredArtifacts)) {
324
+ return { kind: 'deferred' };
223
325
  }
224
326
  // Check if target is a directory
225
- if (targetExists) {
226
- try {
227
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- Path from parsed markdown
228
- if (statSync(targetPath).isDirectory()) {
229
- excludedReferences.push(makeExclusion(targetPath, sourcePath, targetExists, 'directory-target', link));
230
- return true;
231
- }
232
- }
233
- catch {
234
- // statSync failure = skip
235
- return true;
236
- }
237
- }
327
+ const kindVerdict = classifyPathKind(facts);
328
+ if (kindVerdict)
329
+ return kindVerdict;
238
330
  // Check project boundary
239
- if (isOutsideProject(targetPath, options.projectRoot)) {
240
- excludedReferences.push(makeExclusion(targetPath, sourcePath, targetExists, 'outside-project', link));
241
- return true;
242
- }
331
+ if (!facts.insideProject)
332
+ return { kind: 'excluded', reason: 'outside-project' };
243
333
  // Check navigation file exclusion
244
334
  if (options.excludeNavigationFiles && isNavigationBasename(basename(targetPath))) {
245
- excludedReferences.push(makeExclusion(targetPath, sourcePath, targetExists, 'navigation-file', link));
246
- return true;
335
+ return { kind: 'excluded', reason: 'navigation-file' };
247
336
  }
248
337
  // Repo-internal agent-instruction files — see {@link refusesAgentInstructionFile}
249
338
  // for why they cannot travel, and for the explicit-`files:` escape hatch.
250
339
  if (refusesAgentInstructionFile(targetPath, options)) {
251
- excludedReferences.push(makeExclusion(targetPath, sourcePath, targetExists, 'agent-instruction-file', link));
252
- return true;
340
+ return { kind: 'excluded', reason: 'agent-instruction-file' };
253
341
  }
254
342
  // Check for cross-skill SKILL.md links — a SKILL.md is a skill definition marker,
255
343
  // not a resource. Bundling another skill's SKILL.md creates duplicate skill definitions
@@ -260,21 +348,78 @@ function checkExclusions(targetPath, sourcePath, link, options, excludeMatchers,
260
348
  // prevents re-traversal, and the duplicate-definition risk does not apply to the
261
349
  // current skill itself.
262
350
  if (basename(targetPath) === 'SKILL.md') {
263
- if (safePath.resolve(targetPath) !== safePath.resolve(options.skillRootPath)) {
264
- excludedReferences.push(makeExclusion(targetPath, sourcePath, targetExists, 'skill-definition', link));
265
- }
266
- return true;
351
+ return safePath.resolve(targetPath) === safePath.resolve(options.skillRootPath)
352
+ ? { kind: 'skipped' }
353
+ : { kind: 'excluded', reason: 'skill-definition' };
267
354
  }
268
355
  // Check exclude patterns (relative to projectRoot)
269
356
  const relativePath = toForwardSlash(safePath.relative(options.projectRoot, targetPath));
270
357
  const matchedExclude = excludeMatchers.find((m) => m.isMatch(relativePath));
271
358
  if (matchedExclude) {
272
- excludedReferences.push(makeExclusion(targetPath, sourcePath, targetExists, 'pattern-matched', link, matchedExclude.rule));
273
- return true;
359
+ return { kind: 'excluded', reason: 'pattern-matched', matchedRule: matchedExclude.rule };
274
360
  }
275
- // Check if the file is gitignored (prevents leaking data from ignored
276
- // directories) — existence-gated; see {@link checkGitignored}.
277
- return checkGitignored(targetPath, sourcePath, targetExists, link, options, excludedReferences, deferredAssetSet);
361
+ // Falls through to the cascade's final branch, which is the one that needs an
362
+ // oracle rather than a column — see {@link classifyGitignored}.
363
+ return undefined;
364
+ }
365
+ /**
366
+ * The cascade's LAST branch, kept out of {@link classifyExclusion} so that
367
+ * function can stay a pure function of the row.
368
+ *
369
+ * Its position at the end is not an implementation detail — it is why the
370
+ * demand-read is safe. Every earlier discriminator has already declined, so the
371
+ * set of paths this asks the oracle about is exactly the set the shipped
372
+ * classifier asked about, link for link.
373
+ *
374
+ * Existence-gated, and the gate is the point: a path that is not there cannot
375
+ * leak anything, and neither oracle can be trusted to say so — see
376
+ * {@link readGitignored}.
377
+ */
378
+ function classifyGitignored(targetPath, options, facts, state) {
379
+ if (!facts.exists)
380
+ return undefined;
381
+ if (!readGitignored(targetPath, options, state))
382
+ return undefined;
383
+ return classifyGitignoredTarget(targetPath, options.deferredArtifacts);
384
+ }
385
+ /**
386
+ * Apply a verdict to the walk state — the only place the classifier's decision
387
+ * becomes a mutation.
388
+ *
389
+ * @returns true if the caller should stop processing this link
390
+ */
391
+ function applyExclusionVerdict(verdict, targetPath, sourcePath, link, facts, state) {
392
+ switch (verdict.kind) {
393
+ case 'deferred': {
394
+ state.deferredAssetSet.add(toForwardSlash(targetPath));
395
+ return true;
396
+ }
397
+ case 'excluded': {
398
+ state.excludedReferences.push(makeExclusion(targetPath, sourcePath, facts.exists, verdict.reason, link, verdict.matchedRule));
399
+ return true;
400
+ }
401
+ case 'skipped': {
402
+ return true;
403
+ }
404
+ }
405
+ }
406
+ /**
407
+ * Check if a target path should be excluded for structural reasons
408
+ * (deferred build artifact, directory, outside project, navigation file, or
409
+ * pattern match).
410
+ *
411
+ * Pass 1′ fills the row, pass 4 judges it, and the recording is a third step —
412
+ * the three used to be one interleaved function that stat'ed paths mid-cascade.
413
+ *
414
+ * @returns true if the link was excluded or deferred (caller should skip to next link)
415
+ */
416
+ function checkExclusions(targetPath, sourcePath, link, options, excludeMatchers, state) {
417
+ const facts = fillPathFacts(targetPath, options, state);
418
+ const verdict = classifyExclusion(targetPath, options, excludeMatchers, facts)
419
+ ?? classifyGitignored(targetPath, options, facts, state);
420
+ if (verdict === undefined)
421
+ return false;
422
+ return applyExclusionVerdict(verdict, targetPath, sourcePath, link, facts, state);
278
423
  }
279
424
  /**
280
425
  * Process a single local_file link during the graph walk.
@@ -292,7 +437,7 @@ function processLink(link, currentResource, currentDepth, registry, options, exc
292
437
  // Resolve the target path from the link href
293
438
  const targetPath = resolveHrefToPath(hrefWithoutAnchor, currentResource.filePath, options.projectRoot);
294
439
  // Check structural exclusions (deferred, directory, boundary, navigation, pattern, gitignore)
295
- if (checkExclusions(targetPath, currentResource.filePath, link, options, excludeMatchers, state.excludedReferences, state.deferredAssetSet)) {
440
+ if (checkExclusions(targetPath, currentResource.filePath, link, options, excludeMatchers, state)) {
296
441
  return;
297
442
  }
298
443
  // Try to find the target in the registry (markdown files)
@@ -301,22 +446,96 @@ function processLink(link, currentResource, currentDepth, registry, options, exc
301
446
  : registry.getResource(targetPath);
302
447
  if (targetResource) {
303
448
  processRegistryResource(targetResource, targetPath, currentResource.filePath, link, currentDepth, options, state);
304
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- Path from parsed markdown
449
+ // Straight to the probe, not through `fillPathFacts`: this site wants one
450
+ // column, and building a whole row to read it would compute an
451
+ // `insideProject` the caller immediately discards. The probe memo is what
452
+ // makes it free — this used to be a second `existsSync` of the identical
453
+ // path in the same tick.
305
454
  }
306
- else if (existsSync(targetPath)) {
307
- // Not in registry — non-markdown asset that exists on disk
455
+ else if (state.pathProbe.probe(targetPath).exists) {
456
+ // Not in registry — non-markdown asset that exists on disk.
457
+ //
458
+ // ⚠️ `currentDepth` is deliberately NOT consulted here, and its absence is
459
+ // the behaviour, not an oversight. An asset is a leaf: bundling it enqueues
460
+ // nothing, so there is no depth for a limit to bound. A `maxDepth` of 1
461
+ // therefore still ships every image, JSON and PDF hanging off a
462
+ // depth-3 document, provided some routed markdown reached that document.
463
+ // Stated in {@link walkLinkGraph}'s own semantics list ("Non-markdown
464
+ // assets bypass depth limits") and mirrored for non-routable REGISTRY
465
+ // members in {@link processRegistryResource} — do not "fix" one site
466
+ // without the other; they are one rule with two arms.
308
467
  state.bundledAssetSet.add(toForwardSlash(targetPath));
309
468
  }
310
469
  else {
311
470
  // File doesn't exist and not in registry, and not deferred (handled above).
312
471
  // Record as missing-target so downstream emits LINK_MISSING_TARGET.
313
472
  state.excludedReferences.push(makeExclusion(targetPath, currentResource.filePath, false, 'missing-target', link));
473
+ return;
474
+ }
475
+ if (relativeEscapesRoot(safePath.relative(dirname(options.skillRootPath), targetPath))) {
476
+ state.followedOutsideSkillDir.push(makeFollowedLink(targetPath, currentResource.filePath, true, link));
314
477
  }
315
478
  }
316
479
  /**
317
- * Process a registry resource link: check depth, cycle, and either bundle or exclude.
480
+ * Is this file one the walker walks THROUGH, enqueuing its link targets as
481
+ * further bundle members?
482
+ *
483
+ * Routing is markdown-only. HTML may be a registry member — parsed, links
484
+ * known, reachable by the rewriter — without being a door: the decision is
485
+ * "a leaf we can read, not a door we walk through", and it is spec-backed
486
+ * (Anthropic's skill guidance is markdown throughout, and VAT's own stance docs
487
+ * mention HTML nowhere).
488
+ *
489
+ * Keyed on {@link parserKindForPath} rather than a local extension test, so
490
+ * VAT keeps ONE parser discriminator: a format that gains a parser gains a
491
+ * routability answer in the same edit.
492
+ */
493
+ function isRoutable(filePath) {
494
+ return parserKindForPath(filePath) === 'markdown';
495
+ }
496
+ /**
497
+ * Record every local-file link out of a bundled non-routable member as a
498
+ * candidate `non-routable-source` exclusion.
499
+ *
500
+ * Without this the targets simply vanish: `SKILL.md → guide.html →
501
+ * diagram.svg` bundles the HTML, never looks inside it, and ships a bundled
502
+ * page whose `<img src>` points at a file that is not there. A silent drop
503
+ * becomes a reported one.
504
+ */
505
+ function recordUnfollowedLinks(member, options, state) {
506
+ for (const link of member.links) {
507
+ if (!isLocalFileLink(link.type))
508
+ continue;
509
+ const hrefWithoutAnchor = link.href.split('#')[0] ?? link.href;
510
+ if (hrefWithoutAnchor === '')
511
+ continue;
512
+ const targetPath = resolveHrefToPath(hrefWithoutAnchor, member.filePath, options.projectRoot);
513
+ const targetExists = state.pathProbe.probe(targetPath).exists;
514
+ state.unfollowedFromNonRoutable.push(makeExclusion(targetPath, member.filePath, targetExists, 'non-routable-source', link));
515
+ }
516
+ }
517
+ /**
518
+ * Process a registry resource link: check routability, depth, cycle, and either
519
+ * bundle or exclude.
318
520
  */
319
521
  function processRegistryResource(targetResource, targetPath, sourcePath, link, currentDepth, options, state) {
522
+ // A non-routable member is a leaf, and leaves are bundled on the same terms
523
+ // as plain assets — which bypass `maxDepth` entirely. Checking routability
524
+ // BEFORE depth is what makes adding HTML to a registry a pure gain: the file
525
+ // still travels exactly as it did when it was an unparsed asset, it is merely
526
+ // parsed now so the rewriter can reach its links. Applying the depth limit
527
+ // here instead would silently DROP HTML that a markdown-only registry shipped.
528
+ if (!isRoutable(targetResource.filePath)) {
529
+ if (state.visitedResourceIds.has(targetResource.id)) {
530
+ return;
531
+ }
532
+ state.visitedResourceIds.add(targetResource.id);
533
+ state.bundledResourceMap.set(targetResource.id, targetResource);
534
+ // `maxBundledDepth` describes how far the walk ROUTED. A leaf does not
535
+ // extend that, any more than a bundled PNG does.
536
+ recordUnfollowedLinks(targetResource, options, state);
537
+ return;
538
+ }
320
539
  // Check depth limit. A registry resource came from a disk crawl, so it
321
540
  // exists by construction — `targetExists: true` is a fact here, not a guess.
322
541
  if (currentDepth >= options.maxDepth) {
@@ -352,9 +571,36 @@ function processRegistryResource(targetResource, targetPath, sourcePath, link, c
352
571
  * @returns Graph walk result with bundled resources, assets, and exclusions
353
572
  */
354
573
  export function walkLinkGraph(skillResourceId, registry, options) {
574
+ // This walker is not a projection contributor, so the merge driver never sees
575
+ // it and it has no stratum of its own. It records under the synthetic id
576
+ // `walk-link-graph:walk` in the `crawl` stratum — declared in
577
+ // `crawl-timing.ts`, deliberately not an ad-hoc string here, because a
578
+ // synthetic id that arrives by accident is indistinguishable in the dump from
579
+ // a real contributor's.
580
+ //
581
+ // `try`/`finally` rather than a record before each `return`: the early exit
582
+ // for an unknown skill id is a real (cheap) call and must still be counted,
583
+ // and a throw must not silently drop a bracket.
584
+ const startedAt = crawlTimingStart();
585
+ try {
586
+ return walkLinkGraphBody(skillResourceId, registry, options);
587
+ }
588
+ finally {
589
+ recordCrawlPass(CRAWL_WALKER_ID, 'crawl', CRAWL_PASS_INSIDE, startedAt);
590
+ }
591
+ }
592
+ /**
593
+ * The walk itself, with no timing concern in it.
594
+ *
595
+ * @param skillResourceId - The resource ID of the skill's SKILL.md in the registry
596
+ * @param registry - A walkable registry with pre-parsed resources
597
+ * @param options - Walk options (depth, excludes, etc.)
598
+ * @returns Graph walk result with bundled resources, assets, and exclusions
599
+ */
600
+ function walkLinkGraphBody(skillResourceId, registry, options) {
355
601
  const skillResource = registry.getResourceById(skillResourceId);
356
602
  if (!skillResource) {
357
- return { bundledResources: [], bundledAssets: [], excludedReferences: [], maxBundledDepth: 0, deferredAssets: [] };
603
+ return { bundledResources: [], bundledAssets: [], excludedReferences: [], outsideSkillDirLinks: [], maxBundledDepth: 0, deferredAssets: [] };
358
604
  }
359
605
  // Compile exclude patterns once
360
606
  const excludeMatchers = options.excludeRules.map((rule) => ({
@@ -374,6 +620,10 @@ export function walkLinkGraph(skillResourceId, registry, options) {
374
620
  excludedReferences: [],
375
621
  maxBundledDepth: 0,
376
622
  queue: [[skillResource, 0]],
623
+ unfollowedFromNonRoutable: [],
624
+ followedOutsideSkillDir: [],
625
+ pathProbe: options.pathProbe ?? new FsLookupCache(),
626
+ gitignoreFacts: new Map(),
377
627
  };
378
628
  while (state.queue.length > 0) {
379
629
  const entry = state.queue.shift();
@@ -387,10 +637,44 @@ export function walkLinkGraph(skillResourceId, registry, options) {
387
637
  }
388
638
  }
389
639
  }
640
+ // What actually SHIPPED, resolved once the walk is over. Deferred artifacts
641
+ // count as accounted-for: the target will exist once the build materializes
642
+ // it, which is the same reason they are not `missing-target`.
643
+ const bundledPaths = new Set([
644
+ ...state.bundledAssetSet,
645
+ ...state.deferredAssetSet,
646
+ ...[...state.bundledResourceMap.values()].map(r => toForwardSlash(r.filePath)),
647
+ ]);
648
+ const targetShipped = (r) => bundledPaths.has(toForwardSlash(r.path));
649
+ // 🔑 Two reasons ask ONE question — "did the target ship?" — and both can only
650
+ // answer it here, against the FINAL bundle, or the answer depends on queue
651
+ // order.
652
+ //
653
+ // `depth-exceeded` is recorded eagerly, at the moment an edge is refused, and
654
+ // that is correct: the refusal is a fact about the EDGE. It is not a fact
655
+ // about the TARGET, and `LINK_DROPPED_BY_DEPTH` claims the latter — "this link
656
+ // was not bundled". On any diamond the two diverge: the same reference cited
657
+ // from SKILL.md and again from a page one hop deeper ships via the shallow
658
+ // route and is refused on the deep one, and the finding used to fire anyway.
659
+ // Measured on `packages/vat-example-cat-agents`, whose
660
+ // `cat-breed-selection.md` is in the built bundle at depth 1 and was reported
661
+ // dropped for its depth-2 occurrence via `workflow-orchestration.md`. The
662
+ // remedy the message implies — raise `linkFollowDepth` — is a no-op for a link
663
+ // that already works.
664
+ //
665
+ // Filtered in place rather than held back and appended, so the surviving
666
+ // exclusions keep the order the walk found them in.
667
+ const excludedReferences = state.excludedReferences.filter((r) => r.excludeReason !== 'depth-exceeded' || !targetShipped(r));
668
+ for (const unfollowed of state.unfollowedFromNonRoutable) {
669
+ if (!targetShipped(unfollowed)) {
670
+ excludedReferences.push(unfollowed);
671
+ }
672
+ }
390
673
  return {
391
674
  bundledResources: [...state.bundledResourceMap.values()],
392
675
  bundledAssets: [...state.bundledAssetSet],
393
- excludedReferences: state.excludedReferences,
676
+ excludedReferences,
677
+ outsideSkillDirLinks: state.followedOutsideSkillDir.filter(targetShipped),
394
678
  maxBundledDepth: state.maxBundledDepth,
395
679
  deferredAssets: [...state.deferredAssetSet],
396
680
  };