@shrkcrft/cli 0.1.0-alpha.29 → 0.1.0-alpha.31

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 (510) hide show
  1. package/dist/asset-preview/apply-asset-preview.js +1 -1
  2. package/dist/authoring/authoring-kit.d.ts +9 -1
  3. package/dist/authoring/authoring-kit.d.ts.map +1 -1
  4. package/dist/authoring/authoring-kit.js +26 -13
  5. package/dist/bootstrap/unlinked-workspace-dependency.d.ts +11 -0
  6. package/dist/bootstrap/unlinked-workspace-dependency.d.ts.map +1 -0
  7. package/dist/bootstrap/unlinked-workspace-dependency.js +99 -0
  8. package/dist/command-registry.d.ts +100 -1
  9. package/dist/command-registry.d.ts.map +1 -1
  10. package/dist/command-registry.js +130 -3
  11. package/dist/commands/api-diff.command.d.ts.map +1 -1
  12. package/dist/commands/api-diff.command.js +12 -0
  13. package/dist/commands/apply.command.d.ts.map +1 -1
  14. package/dist/commands/apply.command.js +3 -0
  15. package/dist/commands/arch.command.d.ts.map +1 -1
  16. package/dist/commands/arch.command.js +26 -7
  17. package/dist/commands/architecture.command.d.ts.map +1 -1
  18. package/dist/commands/architecture.command.js +8 -2
  19. package/dist/commands/baseline.command.d.ts +76 -0
  20. package/dist/commands/baseline.command.d.ts.map +1 -1
  21. package/dist/commands/baseline.command.js +599 -75
  22. package/dist/commands/biome.command.d.ts.map +1 -1
  23. package/dist/commands/biome.command.js +20 -0
  24. package/dist/commands/boundaries.command.d.ts +9 -1
  25. package/dist/commands/boundaries.command.d.ts.map +1 -1
  26. package/dist/commands/boundaries.command.js +88 -20
  27. package/dist/commands/bundle.command.d.ts.map +1 -1
  28. package/dist/commands/bundle.command.js +57 -11
  29. package/dist/commands/cache-align.command.d.ts.map +1 -1
  30. package/dist/commands/cache-align.command.js +3 -1
  31. package/dist/commands/changelog-data.d.ts.map +1 -1
  32. package/dist/commands/changelog-data.js +164 -0
  33. package/dist/commands/changes.command.d.ts.map +1 -1
  34. package/dist/commands/changes.command.js +15 -0
  35. package/dist/commands/check.command.d.ts.map +1 -1
  36. package/dist/commands/check.command.js +1260 -324
  37. package/dist/commands/checks.command.d.ts.map +1 -1
  38. package/dist/commands/checks.command.js +188 -40
  39. package/dist/commands/ci.command.d.ts.map +1 -1
  40. package/dist/commands/ci.command.js +35 -2
  41. package/dist/commands/code-intel.command.d.ts.map +1 -1
  42. package/dist/commands/code-intel.command.js +8 -1
  43. package/dist/commands/command-catalog.d.ts +23 -1
  44. package/dist/commands/command-catalog.d.ts.map +1 -1
  45. package/dist/commands/command-catalog.js +152 -35
  46. package/dist/commands/commands.command.d.ts +21 -3
  47. package/dist/commands/commands.command.d.ts.map +1 -1
  48. package/dist/commands/commands.command.js +172 -27
  49. package/dist/commands/compress.command.d.ts.map +1 -1
  50. package/dist/commands/compress.command.js +4 -0
  51. package/dist/commands/constructs.command.d.ts.map +1 -1
  52. package/dist/commands/constructs.command.js +50 -12
  53. package/dist/commands/context.command.d.ts.map +1 -1
  54. package/dist/commands/context.command.js +40 -5
  55. package/dist/commands/contract-templates.command.d.ts.map +1 -1
  56. package/dist/commands/contract-templates.command.js +9 -1
  57. package/dist/commands/contract.command.d.ts.map +1 -1
  58. package/dist/commands/contract.command.js +35 -0
  59. package/dist/commands/conventions.command.d.ts.map +1 -1
  60. package/dist/commands/conventions.command.js +189 -29
  61. package/dist/commands/coverage.command.d.ts.map +1 -1
  62. package/dist/commands/coverage.command.js +9 -0
  63. package/dist/commands/daily.commands.d.ts +0 -1
  64. package/dist/commands/daily.commands.d.ts.map +1 -1
  65. package/dist/commands/daily.commands.js +12 -76
  66. package/dist/commands/dashboard.command.d.ts.map +1 -1
  67. package/dist/commands/dashboard.command.js +10 -0
  68. package/dist/commands/dev.command.d.ts.map +1 -1
  69. package/dist/commands/dev.command.js +45 -2
  70. package/dist/commands/diagnostics.command.d.ts +0 -2
  71. package/dist/commands/diagnostics.command.d.ts.map +1 -1
  72. package/dist/commands/diagnostics.command.js +5 -78
  73. package/dist/commands/diff-check.command.d.ts +6 -4
  74. package/dist/commands/diff-check.command.d.ts.map +1 -1
  75. package/dist/commands/diff-check.command.js +190 -103
  76. package/dist/commands/docs-references.command.d.ts +20 -0
  77. package/dist/commands/docs-references.command.d.ts.map +1 -0
  78. package/dist/commands/docs-references.command.js +520 -0
  79. package/dist/commands/doctor.command.d.ts.map +1 -1
  80. package/dist/commands/doctor.command.js +60 -5
  81. package/dist/commands/drift.command.d.ts.map +1 -1
  82. package/dist/commands/drift.command.js +17 -4
  83. package/dist/commands/eslint.command.d.ts.map +1 -1
  84. package/dist/commands/eslint.command.js +20 -0
  85. package/dist/commands/export.command.d.ts.map +1 -1
  86. package/dist/commands/export.command.js +23 -1
  87. package/dist/commands/feedback-dispatch.command.d.ts.map +1 -1
  88. package/dist/commands/feedback-dispatch.command.js +29 -0
  89. package/dist/commands/feedback.command.d.ts.map +1 -1
  90. package/dist/commands/feedback.command.js +6 -1
  91. package/dist/commands/finish.command.d.ts.map +1 -1
  92. package/dist/commands/finish.command.js +32 -8
  93. package/dist/commands/fix.command.d.ts.map +1 -1
  94. package/dist/commands/fix.command.js +19 -0
  95. package/dist/commands/gate.command.d.ts +6 -1
  96. package/dist/commands/gate.command.d.ts.map +1 -1
  97. package/dist/commands/gate.command.js +50 -124
  98. package/dist/commands/gates.command.d.ts +54 -0
  99. package/dist/commands/gates.command.d.ts.map +1 -1
  100. package/dist/commands/gates.command.js +1314 -64
  101. package/dist/commands/gen.command.d.ts.map +1 -1
  102. package/dist/commands/gen.command.js +4 -0
  103. package/dist/commands/generated.command.d.ts +62 -0
  104. package/dist/commands/generated.command.d.ts.map +1 -1
  105. package/dist/commands/generated.command.js +639 -156
  106. package/dist/commands/graph-code-subverbs.d.ts +15 -0
  107. package/dist/commands/graph-code-subverbs.d.ts.map +1 -1
  108. package/dist/commands/graph-code-subverbs.js +203 -25
  109. package/dist/commands/graph.command.d.ts.map +1 -1
  110. package/dist/commands/graph.command.js +79 -37
  111. package/dist/commands/help.command.d.ts +30 -6
  112. package/dist/commands/help.command.d.ts.map +1 -1
  113. package/dist/commands/help.command.js +323 -199
  114. package/dist/commands/helper.command.d.ts +1 -0
  115. package/dist/commands/helper.command.d.ts.map +1 -1
  116. package/dist/commands/helper.command.js +195 -56
  117. package/dist/commands/impact.command.d.ts.map +1 -1
  118. package/dist/commands/impact.command.js +115 -12
  119. package/dist/commands/infer.command.d.ts.map +1 -1
  120. package/dist/commands/infer.command.js +20 -45
  121. package/dist/commands/ingest.command.d.ts +0 -1
  122. package/dist/commands/ingest.command.d.ts.map +1 -1
  123. package/dist/commands/ingest.command.js +25 -41
  124. package/dist/commands/knowledge-author.command.d.ts +0 -3
  125. package/dist/commands/knowledge-author.command.d.ts.map +1 -1
  126. package/dist/commands/knowledge-author.command.js +10 -23
  127. package/dist/commands/knowledge.command.d.ts.map +1 -1
  128. package/dist/commands/knowledge.command.js +469 -129
  129. package/dist/commands/languages.command.d.ts.map +1 -1
  130. package/dist/commands/languages.command.js +27 -0
  131. package/dist/commands/lint.command.d.ts.map +1 -1
  132. package/dist/commands/lint.command.js +3 -0
  133. package/dist/commands/mcp.command.d.ts +18 -0
  134. package/dist/commands/mcp.command.d.ts.map +1 -1
  135. package/dist/commands/mcp.command.js +33 -8
  136. package/dist/commands/onboard.command.d.ts.map +1 -1
  137. package/dist/commands/onboard.command.js +20 -0
  138. package/dist/commands/owners.command.d.ts.map +1 -1
  139. package/dist/commands/owners.command.js +14 -2
  140. package/dist/commands/packs-new.d.ts +13 -1
  141. package/dist/commands/packs-new.d.ts.map +1 -1
  142. package/dist/commands/packs-new.js +500 -335
  143. package/dist/commands/packs.command.d.ts.map +1 -1
  144. package/dist/commands/packs.command.js +378 -36
  145. package/dist/commands/paths.command.d.ts.map +1 -1
  146. package/dist/commands/paths.command.js +9 -1
  147. package/dist/commands/pipelines.command.d.ts.map +1 -1
  148. package/dist/commands/pipelines.command.js +7 -1
  149. package/dist/commands/plan-check.command.d.ts.map +1 -1
  150. package/dist/commands/plan-check.command.js +3 -0
  151. package/dist/commands/plan.command.d.ts.map +1 -1
  152. package/dist/commands/plan.command.js +3 -0
  153. package/dist/commands/playbooks.command.d.ts.map +1 -1
  154. package/dist/commands/playbooks.command.js +7 -2
  155. package/dist/commands/policy-lint.command.d.ts +19 -2
  156. package/dist/commands/policy-lint.command.d.ts.map +1 -1
  157. package/dist/commands/policy-lint.command.js +224 -65
  158. package/dist/commands/policy.command.d.ts.map +1 -1
  159. package/dist/commands/policy.command.js +6 -1
  160. package/dist/commands/pr.command.d.ts.map +1 -1
  161. package/dist/commands/pr.command.js +14 -0
  162. package/dist/commands/presets.command.d.ts.map +1 -1
  163. package/dist/commands/presets.command.js +12 -27
  164. package/dist/commands/profiles.command.d.ts.map +1 -1
  165. package/dist/commands/profiles.command.js +94 -26
  166. package/dist/commands/provenance.command.js +1 -1
  167. package/dist/commands/quality.command.d.ts.map +1 -1
  168. package/dist/commands/quality.command.js +129 -33
  169. package/dist/commands/recommend.command.d.ts +20 -4
  170. package/dist/commands/recommend.command.d.ts.map +1 -1
  171. package/dist/commands/recommend.command.js +172 -278
  172. package/dist/commands/registrations.command.d.ts.map +1 -1
  173. package/dist/commands/registrations.command.js +138 -13
  174. package/dist/commands/registry-lifecycle-run.d.ts +14 -0
  175. package/dist/commands/registry-lifecycle-run.d.ts.map +1 -0
  176. package/dist/commands/registry-lifecycle-run.js +336 -0
  177. package/dist/commands/registry.command.d.ts.map +1 -1
  178. package/dist/commands/registry.command.js +235 -62
  179. package/dist/commands/release.command.d.ts.map +1 -1
  180. package/dist/commands/release.command.js +99 -20
  181. package/dist/commands/report.command.d.ts.map +1 -1
  182. package/dist/commands/report.command.js +38 -2
  183. package/dist/commands/reuse-coverage.command.d.ts +23 -0
  184. package/dist/commands/reuse-coverage.command.d.ts.map +1 -0
  185. package/dist/commands/reuse-coverage.command.js +536 -0
  186. package/dist/commands/reuse.command.d.ts +3 -18
  187. package/dist/commands/reuse.command.d.ts.map +1 -1
  188. package/dist/commands/reuse.command.js +395 -244
  189. package/dist/commands/review.command.d.ts.map +1 -1
  190. package/dist/commands/review.command.js +33 -1
  191. package/dist/commands/rounds.command.d.ts.map +1 -1
  192. package/dist/commands/rounds.command.js +5 -0
  193. package/dist/commands/safety.command.d.ts.map +1 -1
  194. package/dist/commands/safety.command.js +9 -0
  195. package/dist/commands/scaffolds.command.d.ts.map +1 -1
  196. package/dist/commands/scaffolds.command.js +97 -25
  197. package/dist/commands/search.command.d.ts +0 -8
  198. package/dist/commands/search.command.d.ts.map +1 -1
  199. package/dist/commands/search.command.js +136 -28
  200. package/dist/commands/self-config-xrefs.command.d.ts +24 -0
  201. package/dist/commands/self-config-xrefs.command.d.ts.map +1 -0
  202. package/dist/commands/self-config-xrefs.command.js +170 -0
  203. package/dist/commands/self-config.command.d.ts.map +1 -1
  204. package/dist/commands/self-config.command.js +261 -65
  205. package/dist/commands/smart-context.command.d.ts.map +1 -1
  206. package/dist/commands/smart-context.command.js +39 -3
  207. package/dist/commands/spec.command.d.ts.map +1 -1
  208. package/dist/commands/spec.command.js +3 -0
  209. package/dist/commands/stats.command.d.ts.map +1 -1
  210. package/dist/commands/stats.command.js +3 -0
  211. package/dist/commands/surface.command.d.ts +6 -1
  212. package/dist/commands/surface.command.d.ts.map +1 -1
  213. package/dist/commands/surface.command.js +256 -54
  214. package/dist/commands/task-context.command.js +1 -1
  215. package/dist/commands/task.command.d.ts.map +1 -1
  216. package/dist/commands/task.command.js +22 -1
  217. package/dist/commands/templates.command.d.ts +4 -0
  218. package/dist/commands/templates.command.d.ts.map +1 -1
  219. package/dist/commands/templates.command.js +109 -22
  220. package/dist/commands/test.command.d.ts.map +1 -1
  221. package/dist/commands/test.command.js +161 -33
  222. package/dist/commands/tests.command.d.ts.map +1 -1
  223. package/dist/commands/tests.command.js +29 -3
  224. package/dist/commands/trace.command.d.ts.map +1 -1
  225. package/dist/commands/trace.command.js +29 -4
  226. package/dist/commands/watch.command.d.ts.map +1 -1
  227. package/dist/commands/watch.command.js +3 -0
  228. package/dist/commands/why.command.d.ts.map +1 -1
  229. package/dist/commands/why.command.js +3 -0
  230. package/dist/commands/wiring.command.d.ts +18 -4
  231. package/dist/commands/wiring.command.d.ts.map +1 -1
  232. package/dist/commands/wiring.command.js +362 -74
  233. package/dist/dashboard/code-intelligence-data.js +4 -4
  234. package/dist/diff/deleted-orphan-coverage.d.ts +32 -0
  235. package/dist/diff/deleted-orphan-coverage.d.ts.map +1 -0
  236. package/dist/diff/deleted-orphan-coverage.js +107 -0
  237. package/dist/diff/deleted-orphan-scope-notes.d.ts +23 -0
  238. package/dist/diff/deleted-orphan-scope-notes.d.ts.map +1 -0
  239. package/dist/diff/deleted-orphan-scope-notes.js +49 -0
  240. package/dist/diff/deleted-orphans.d.ts +32 -0
  241. package/dist/diff/deleted-orphans.d.ts.map +1 -1
  242. package/dist/diff/deleted-orphans.js +42 -2
  243. package/dist/dispatch/closest-match.d.ts +15 -0
  244. package/dist/dispatch/closest-match.d.ts.map +1 -0
  245. package/dist/dispatch/closest-match.js +47 -0
  246. package/dist/dispatch/global-flags.d.ts +94 -0
  247. package/dist/dispatch/global-flags.d.ts.map +1 -0
  248. package/dist/dispatch/global-flags.js +123 -0
  249. package/dist/dispatch/guard-invocation-input.d.ts +24 -0
  250. package/dist/dispatch/guard-invocation-input.d.ts.map +1 -0
  251. package/dist/dispatch/guard-invocation-input.js +1 -0
  252. package/dist/dispatch/guard-invocation.d.ts +33 -0
  253. package/dist/dispatch/guard-invocation.d.ts.map +1 -0
  254. package/dist/dispatch/guard-invocation.js +183 -0
  255. package/dist/dispatch/help-intercept.d.ts +22 -0
  256. package/dist/dispatch/help-intercept.d.ts.map +1 -0
  257. package/dist/dispatch/help-intercept.js +42 -0
  258. package/dist/dispatch/i-declared-invocation.d.ts +17 -0
  259. package/dist/dispatch/i-declared-invocation.d.ts.map +1 -0
  260. package/dist/dispatch/i-declared-invocation.js +1 -0
  261. package/dist/dispatch/i-unknown-flag-refusal-input.d.ts +28 -0
  262. package/dist/dispatch/i-unknown-flag-refusal-input.d.ts.map +1 -0
  263. package/dist/dispatch/i-unknown-flag-refusal-input.js +1 -0
  264. package/dist/dispatch/invocation-rejection-kind.d.ts +15 -0
  265. package/dist/dispatch/invocation-rejection-kind.d.ts.map +1 -0
  266. package/dist/dispatch/invocation-rejection-kind.js +15 -0
  267. package/dist/dispatch/invocation-rejection.d.ts +18 -0
  268. package/dist/dispatch/invocation-rejection.d.ts.map +1 -0
  269. package/dist/dispatch/invocation-rejection.js +1 -0
  270. package/dist/dispatch/judge-invocation.d.ts +24 -0
  271. package/dist/dispatch/judge-invocation.d.ts.map +1 -0
  272. package/dist/dispatch/judge-invocation.js +50 -0
  273. package/dist/dispatch/positional-mode.d.ts +18 -0
  274. package/dist/dispatch/positional-mode.d.ts.map +1 -0
  275. package/dist/dispatch/positional-mode.js +18 -0
  276. package/dist/dispatch/read-tracking-map.d.ts +28 -0
  277. package/dist/dispatch/read-tracking-map.d.ts.map +1 -0
  278. package/dist/dispatch/read-tracking-map.js +58 -0
  279. package/dist/dispatch/refuse-sibling-valve-flags.d.ts +30 -0
  280. package/dist/dispatch/refuse-sibling-valve-flags.d.ts.map +1 -0
  281. package/dist/dispatch/refuse-sibling-valve-flags.js +65 -0
  282. package/dist/dispatch/subverb-spec.d.ts +26 -0
  283. package/dist/dispatch/subverb-spec.d.ts.map +1 -0
  284. package/dist/dispatch/subverb-spec.js +1 -0
  285. package/dist/dispatch/undocumented-flag-reads.d.ts +22 -0
  286. package/dist/dispatch/undocumented-flag-reads.d.ts.map +1 -0
  287. package/dist/dispatch/undocumented-flag-reads.js +283 -0
  288. package/dist/dispatch/unknown-flag-refusal-mode.d.ts +13 -0
  289. package/dist/dispatch/unknown-flag-refusal-mode.d.ts.map +1 -0
  290. package/dist/dispatch/unknown-flag-refusal-mode.js +13 -0
  291. package/dist/dispatch/unknown-flag-refusal.d.ts +22 -0
  292. package/dist/dispatch/unknown-flag-refusal.d.ts.map +1 -0
  293. package/dist/dispatch/unknown-flag-refusal.js +57 -0
  294. package/dist/dispatch/unread-flags.d.ts +152 -0
  295. package/dist/dispatch/unread-flags.d.ts.map +1 -0
  296. package/dist/dispatch/unread-flags.js +295 -0
  297. package/dist/dispatch/verdict-valve-flag.d.ts +21 -0
  298. package/dist/dispatch/verdict-valve-flag.d.ts.map +1 -0
  299. package/dist/dispatch/verdict-valve-flag.js +21 -0
  300. package/dist/dispatch/walk-declared-subverbs.d.ts +14 -0
  301. package/dist/dispatch/walk-declared-subverbs.d.ts.map +1 -0
  302. package/dist/dispatch/walk-declared-subverbs.js +28 -0
  303. package/dist/exit-codes.d.ts +41 -3
  304. package/dist/exit-codes.d.ts.map +1 -1
  305. package/dist/exit-codes.js +153 -14
  306. package/dist/finish/run-finish.d.ts +72 -5
  307. package/dist/finish/run-finish.d.ts.map +1 -1
  308. package/dist/finish/run-finish.js +453 -104
  309. package/dist/gates/accepted-empty-note.d.ts +10 -0
  310. package/dist/gates/accepted-empty-note.d.ts.map +1 -0
  311. package/dist/gates/accepted-empty-note.js +11 -0
  312. package/dist/gates/allow-empty.d.ts +25 -0
  313. package/dist/gates/allow-empty.d.ts.map +1 -0
  314. package/dist/gates/allow-empty.js +27 -0
  315. package/dist/gates/asset-doctor-failing-units.d.ts +16 -0
  316. package/dist/gates/asset-doctor-failing-units.d.ts.map +1 -0
  317. package/dist/gates/asset-doctor-failing-units.js +14 -0
  318. package/dist/gates/asset-doctor-failure-line.d.ts +16 -0
  319. package/dist/gates/asset-doctor-failure-line.d.ts.map +1 -0
  320. package/dist/gates/asset-doctor-failure-line.js +28 -0
  321. package/dist/gates/empty-rule-advice-lines.d.ts +15 -0
  322. package/dist/gates/empty-rule-advice-lines.d.ts.map +1 -0
  323. package/dist/gates/empty-rule-advice-lines.js +20 -0
  324. package/dist/gates/gate-envelope.d.ts +85 -8
  325. package/dist/gates/gate-envelope.d.ts.map +1 -1
  326. package/dist/gates/gate-envelope.js +56 -7
  327. package/dist/gates/gate-rule-globs.d.ts +67 -0
  328. package/dist/gates/gate-rule-globs.d.ts.map +1 -0
  329. package/dist/gates/gate-rule-globs.js +139 -0
  330. package/dist/gates/gate-rule-view.d.ts +25 -6
  331. package/dist/gates/gate-rule-view.d.ts.map +1 -1
  332. package/dist/gates/gate-rule-view.js +105 -77
  333. package/dist/gates/i-coverage-dead-glob.d.ts +19 -0
  334. package/dist/gates/i-coverage-dead-glob.d.ts.map +1 -0
  335. package/dist/gates/i-coverage-dead-glob.js +1 -0
  336. package/dist/gates/i-coverage-negation.d.ts +13 -0
  337. package/dist/gates/i-coverage-negation.d.ts.map +1 -0
  338. package/dist/gates/i-coverage-negation.js +1 -0
  339. package/dist/gates/i-registration-query-settle-input.d.ts +18 -0
  340. package/dist/gates/i-registration-query-settle-input.d.ts.map +1 -0
  341. package/dist/gates/i-registration-query-settle-input.js +1 -0
  342. package/dist/gates/i-unit-state-note-row.d.ts +16 -0
  343. package/dist/gates/i-unit-state-note-row.d.ts.map +1 -0
  344. package/dist/gates/i-unit-state-note-row.js +1 -0
  345. package/dist/gates/i-unit-state-notes.d.ts +32 -0
  346. package/dist/gates/i-unit-state-notes.d.ts.map +1 -0
  347. package/dist/gates/i-unit-state-notes.js +1 -0
  348. package/dist/gates/incomplete-registry-inventory.d.ts +19 -0
  349. package/dist/gates/incomplete-registry-inventory.d.ts.map +1 -0
  350. package/dist/gates/incomplete-registry-inventory.js +63 -0
  351. package/dist/gates/measure-registration-roles.d.ts +10 -0
  352. package/dist/gates/measure-registration-roles.d.ts.map +1 -0
  353. package/dist/gates/measure-registration-roles.js +9 -0
  354. package/dist/gates/plane-verdict.d.ts +14 -0
  355. package/dist/gates/plane-verdict.d.ts.map +1 -0
  356. package/dist/gates/plane-verdict.js +22 -0
  357. package/dist/gates/qualify-clean-for-units.d.ts +12 -0
  358. package/dist/gates/qualify-clean-for-units.d.ts.map +1 -0
  359. package/dist/gates/qualify-clean-for-units.js +18 -0
  360. package/dist/gates/registration-graph-verdict.d.ts +34 -0
  361. package/dist/gates/registration-graph-verdict.d.ts.map +1 -0
  362. package/dist/gates/registration-graph-verdict.js +70 -0
  363. package/dist/gates/registration-roles.d.ts +9 -0
  364. package/dist/gates/registration-roles.d.ts.map +1 -0
  365. package/dist/gates/registration-roles.js +1 -0
  366. package/dist/gates/registry-liveness.d.ts +11 -0
  367. package/dist/gates/registry-liveness.d.ts.map +1 -0
  368. package/dist/gates/registry-liveness.js +13 -0
  369. package/dist/gates/rule-coverage.d.ts +225 -4
  370. package/dist/gates/rule-coverage.d.ts.map +1 -1
  371. package/dist/gates/rule-coverage.js +610 -43
  372. package/dist/gates/run-gate-planes.d.ts +58 -0
  373. package/dist/gates/run-gate-planes.d.ts.map +1 -0
  374. package/dist/gates/run-gate-planes.js +403 -0
  375. package/dist/gates/scaffold-selftest.d.ts +56 -0
  376. package/dist/gates/scaffold-selftest.d.ts.map +1 -0
  377. package/dist/gates/scaffold-selftest.js +119 -0
  378. package/dist/gates/seam-rejected-rules.d.ts +28 -0
  379. package/dist/gates/seam-rejected-rules.d.ts.map +1 -0
  380. package/dist/gates/seam-rejected-rules.js +95 -0
  381. package/dist/gates/self-test-check.d.ts +29 -0
  382. package/dist/gates/self-test-check.d.ts.map +1 -0
  383. package/dist/gates/self-test-check.js +1 -0
  384. package/dist/gates/self-test-subject.d.ts +28 -0
  385. package/dist/gates/self-test-subject.d.ts.map +1 -0
  386. package/dist/gates/self-test-subject.js +1 -0
  387. package/dist/gates/self-test.d.ts +20 -0
  388. package/dist/gates/self-test.d.ts.map +1 -0
  389. package/dist/gates/self-test.js +82 -0
  390. package/dist/gates/settle-verdict.d.ts +14 -0
  391. package/dist/gates/settle-verdict.d.ts.map +1 -0
  392. package/dist/gates/settle-verdict.js +13 -0
  393. package/dist/gates/settled-verdict.d.ts +8 -0
  394. package/dist/gates/settled-verdict.d.ts.map +1 -0
  395. package/dist/gates/settled-verdict.js +1 -0
  396. package/dist/gates/template-registry-coverage.d.ts +18 -0
  397. package/dist/gates/template-registry-coverage.d.ts.map +1 -0
  398. package/dist/gates/template-registry-coverage.js +24 -0
  399. package/dist/gates/unit-state-notes.d.ts +40 -0
  400. package/dist/gates/unit-state-notes.d.ts.map +1 -0
  401. package/dist/gates/unit-state-notes.js +71 -0
  402. package/dist/gates/verdict-line.d.ts +18 -0
  403. package/dist/gates/verdict-line.d.ts.map +1 -0
  404. package/dist/gates/verdict-line.js +40 -0
  405. package/dist/graph/graph-reuse-lookup.d.ts +10 -0
  406. package/dist/graph/graph-reuse-lookup.d.ts.map +1 -0
  407. package/dist/graph/graph-reuse-lookup.js +41 -0
  408. package/dist/graph/index-behind-hint.d.ts +11 -0
  409. package/dist/graph/index-behind-hint.d.ts.map +1 -0
  410. package/dist/graph/index-behind-hint.js +27 -0
  411. package/dist/knowledge/knowledge-stale-gate-flags.d.ts +3 -0
  412. package/dist/knowledge/knowledge-stale-gate-flags.d.ts.map +1 -0
  413. package/dist/knowledge/knowledge-stale-gate-flags.js +1 -0
  414. package/dist/knowledge/knowledge-stale-gate-input.d.ts +3 -0
  415. package/dist/knowledge/knowledge-stale-gate-input.d.ts.map +1 -0
  416. package/dist/knowledge/knowledge-stale-gate-input.js +1 -0
  417. package/dist/knowledge/knowledge-stale-gate-result.d.ts +3 -0
  418. package/dist/knowledge/knowledge-stale-gate-result.d.ts.map +1 -0
  419. package/dist/knowledge/knowledge-stale-gate-result.js +1 -0
  420. package/dist/knowledge/knowledge-stale-gate.d.ts +10 -0
  421. package/dist/knowledge/knowledge-stale-gate.d.ts.map +1 -0
  422. package/dist/knowledge/knowledge-stale-gate.js +9 -0
  423. package/dist/main.d.ts.map +1 -1
  424. package/dist/main.js +204 -83
  425. package/dist/output/failure-hints.d.ts.map +1 -1
  426. package/dist/output/failure-hints.js +11 -9
  427. package/dist/output/rejected-entries-note.d.ts +57 -0
  428. package/dist/output/rejected-entries-note.d.ts.map +1 -0
  429. package/dist/output/rejected-entries-note.js +103 -0
  430. package/dist/output/stdout-is-pipe.d.ts +13 -0
  431. package/dist/output/stdout-is-pipe.d.ts.map +1 -0
  432. package/dist/output/stdout-is-pipe.js +21 -0
  433. package/dist/packs/stale-build-warning.d.ts +11 -0
  434. package/dist/packs/stale-build-warning.d.ts.map +1 -0
  435. package/dist/packs/stale-build-warning.js +23 -0
  436. package/dist/quality/run-quality.d.ts +123 -0
  437. package/dist/quality/run-quality.d.ts.map +1 -0
  438. package/dist/quality/run-quality.js +442 -0
  439. package/dist/schemas/json-schemas.d.ts +184 -3
  440. package/dist/schemas/json-schemas.d.ts.map +1 -1
  441. package/dist/schemas/json-schemas.js +113 -2
  442. package/dist/shrk.d.ts +3 -0
  443. package/dist/shrk.d.ts.map +1 -0
  444. package/dist/shrk.js +30 -0
  445. package/dist/surface/audience-applicability.d.ts +26 -0
  446. package/dist/surface/audience-applicability.d.ts.map +1 -0
  447. package/dist/surface/audience-applicability.js +60 -0
  448. package/dist/surface/catalog-command-safety.d.ts +18 -0
  449. package/dist/surface/catalog-command-safety.d.ts.map +1 -0
  450. package/dist/surface/catalog-command-safety.js +62 -0
  451. package/dist/surface/cli-command-resolver.d.ts +21 -0
  452. package/dist/surface/cli-command-resolver.d.ts.map +1 -0
  453. package/dist/surface/cli-command-resolver.js +87 -0
  454. package/dist/surface/command-dispatch-kind.d.ts +20 -0
  455. package/dist/surface/command-dispatch-kind.d.ts.map +1 -0
  456. package/dist/surface/command-dispatch-kind.js +20 -0
  457. package/dist/surface/command-index-entry.d.ts +40 -0
  458. package/dist/surface/command-index-entry.d.ts.map +1 -0
  459. package/dist/surface/command-index-entry.js +1 -0
  460. package/dist/surface/command-index.d.ts +77 -0
  461. package/dist/surface/command-index.d.ts.map +1 -0
  462. package/dist/surface/command-index.js +323 -0
  463. package/dist/surface/i-command-index.d.ts +22 -0
  464. package/dist/surface/i-command-index.d.ts.map +1 -0
  465. package/dist/surface/i-command-index.js +1 -0
  466. package/dist/surface/i-command-string-context.d.ts +20 -0
  467. package/dist/surface/i-command-string-context.d.ts.map +1 -0
  468. package/dist/surface/i-command-string-context.js +1 -0
  469. package/dist/surface/i-surface-denial.d.ts +13 -0
  470. package/dist/surface/i-surface-denial.d.ts.map +1 -0
  471. package/dist/surface/i-surface-denial.js +1 -0
  472. package/dist/surface/load-surface-context.d.ts +17 -1
  473. package/dist/surface/load-surface-context.d.ts.map +1 -1
  474. package/dist/surface/load-surface-context.js +48 -10
  475. package/dist/surface/not-enabled-error.d.ts +38 -4
  476. package/dist/surface/not-enabled-error.d.ts.map +1 -1
  477. package/dist/surface/not-enabled-error.js +96 -6
  478. package/dist/surface/profiles.d.ts +7 -0
  479. package/dist/surface/profiles.d.ts.map +1 -1
  480. package/dist/surface/resolve-command-string.d.ts +10 -0
  481. package/dist/surface/resolve-command-string.d.ts.map +1 -0
  482. package/dist/surface/resolve-command-string.js +572 -0
  483. package/dist/surface/surface-config-writer.d.ts +7 -2
  484. package/dist/surface/surface-config-writer.d.ts.map +1 -1
  485. package/dist/surface/surface-config-writer.js +25 -6
  486. package/dist/surface/surface-layer.d.ts +11 -0
  487. package/dist/surface/surface-layer.d.ts.map +1 -0
  488. package/dist/surface/surface-layer.js +11 -0
  489. package/dist/surface/surface-refusal-reason.d.ts +16 -0
  490. package/dist/surface/surface-refusal-reason.d.ts.map +1 -0
  491. package/dist/surface/surface-refusal-reason.js +16 -0
  492. package/dist/surface/surface-selector.d.ts +26 -0
  493. package/dist/surface/surface-selector.d.ts.map +1 -0
  494. package/dist/surface/surface-selector.js +42 -0
  495. package/dist/surface/surface-summary.d.ts +61 -18
  496. package/dist/surface/surface-summary.d.ts.map +1 -1
  497. package/dist/surface/surface-summary.js +147 -60
  498. package/dist/surface/tier.d.ts +85 -22
  499. package/dist/surface/tier.d.ts.map +1 -1
  500. package/dist/surface/tier.js +132 -30
  501. package/dist/usage/usage-log.d.ts +6 -1
  502. package/dist/usage/usage-log.d.ts.map +1 -1
  503. package/dist/usage/usage-log.js +7 -2
  504. package/dist/validation/run-validation-loop.d.ts +9 -0
  505. package/dist/validation/run-validation-loop.d.ts.map +1 -1
  506. package/dist/validation/run-validation-loop.js +14 -11
  507. package/dist/validation/typecheck-emitted.d.ts +8 -9
  508. package/dist/validation/typecheck-emitted.d.ts.map +1 -1
  509. package/dist/validation/typecheck-emitted.js +17 -97
  510. package/package.json +34 -34
@@ -1,31 +1,60 @@
1
- import { buildAiReadinessReport, buildImportHygieneReport, buildPackDoctorReport, ChangedScopeMode, diagnoseActionHints, emitImportHygieneAllowlistDraft, filterViolationsToChangedScope, ImportHygieneFindingKind, inspectSharkcraft, isTodoReason, renderImportHygieneText, resolveChangedFiles, resolveProjectConfig, runDoctor, suggestBoundaryFixes, } from '@shrkcrft/inspector';
1
+ import { boundaryLoadIssueCoverage, boundaryLoadIssueLabel, boundaryLoadIssuesFromFile, buildAiReadinessReport, boundaryRulesCheckedNothing, boundaryRulesAcceptedEmpty, boundaryRulesEvaluated, boundarySkippedRuleRows, buildImportHygieneReport, buildPackDoctorReportAsync, buildPolyglotBoundaryReport, importHygieneCoverage, packDoctorCoverage, packDoctorVerdict, diagnoseActionHints, diffBoundaryRuleSets, emitImportHygieneAllowlistDraft, filterViolationsToChangedScope, ImportHygieneFindingKind, inspectSharkcraft, isTodoReason, renderImportHygieneText, resolveChangedFiles, resolveProjectConfig, runBoundaryCheck, doctorVerdict, DoctorVerdictKind, runDoctor, suggestBoundaryFixes, } from '@shrkcrft/inspector';
2
2
  import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
3
3
  import * as nodePath from 'node:path';
4
- import { flagBool, flagNumber, flagString, flagVars, resolveCwd, } from "../command-registry.js";
4
+ import { firstUnknownFlag, flagBool, flagNumber, flagString, flagVars, resolveCwd, } from "../command-registry.js";
5
+ import { GLOBAL_FLAGS, IMPLICIT_FLAGS } from "../dispatch/global-flags.js";
6
+ import { unknownFlagRefusal } from "../dispatch/unknown-flag-refusal.js";
7
+ import { PositionalMode } from "../dispatch/positional-mode.js";
5
8
  import { asJson, header, kv } from "../output/format-output.js";
6
9
  import { ExitCode } from "../exit-codes.js";
7
10
  import { maybeRunInWatchMode } from "../output/watch-loop.js";
8
11
  import { computeDeletedOrphans } from "../diff/deleted-orphans.js";
9
- import { renderWiringExplain } from "./wiring.command.js";
12
+ import { deletedOrphanCoverage } from "../diff/deleted-orphan-coverage.js";
13
+ import { deletedOrphanScopeNotes } from "../diff/deleted-orphan-scope-notes.js";
14
+ import { renderWiringExplain, settleWiringExplain } from "./wiring.command.js";
15
+ import { runRegistryLifecycle } from "./registry-lifecycle-run.js";
10
16
  import { validateTemplateVariables } from '@shrkcrft/templates';
11
17
  import { FileChangeType, planGeneration } from '@shrkcrft/generator';
12
- import { evaluateBoundaries, explainWiring, loadTsconfigPaths, runWiring, scanImports, summarizeImports, planWiringFix, readMatchingFiles, wiringGlobsOf, } from '@shrkcrft/boundaries';
13
- import { buildGateEnvelope } from "../gates/gate-envelope.js";
14
- function knowledgeGroup(inspection) {
18
+ import { explainWiring, loadBoundaryRulesFromFile, runWiring, summarizeImports, planWiringFix, planeScanExcludeDirs, readMatchingFiles, wiringGlobsOf, } from '@shrkcrft/boundaries';
19
+ import { coverageShortfall, formatCoverage, UnitLivenessState, } from '@shrkcrft/core';
20
+ import { buildGateEnvelope, } from "../gates/gate-envelope.js";
21
+ import { ALLOW_EMPTY_FLAG, allowEmptyValve } from "../gates/allow-empty.js";
22
+ import { emptyRuleAdviceLines } from "../gates/empty-rule-advice-lines.js";
23
+ import { qualifyCleanForUnits } from "../gates/qualify-clean-for-units.js";
24
+ import { unitStateNotes } from "../gates/unit-state-notes.js";
25
+ import { templateRegistryCoverage } from "../gates/template-registry-coverage.js";
26
+ import { verdictLine } from "../gates/verdict-line.js";
27
+ import { acceptedEmptyNote } from "../gates/accepted-empty-note.js";
28
+ import { settleVerdict } from "../gates/settle-verdict.js";
29
+ import { planeVerdictForExit } from "../gates/plane-verdict.js";
30
+ import { seamRejectedRules } from "../gates/seam-rejected-rules.js";
31
+ function knowledgeGroup(inspection, args) {
15
32
  const dup = inspection.validationIssues.filter((i) => i.code === 'duplicate-id');
16
33
  const missing = inspection.validationIssues.filter((i) => i.code !== 'duplicate-id');
34
+ const loaded = inspection.knowledgeEntries.length;
17
35
  return {
18
36
  name: 'knowledge',
19
37
  passed: missing.length === 0 && dup.length === 0,
20
38
  errors: missing.length,
21
39
  warnings: dup.length,
40
+ ...(loaded === 0 && missing.length === 0 ? { examinedNothing: true } : {}),
41
+ requestCoverage: [
42
+ {
43
+ unit: 'knowledge entries',
44
+ expected: loaded,
45
+ examined: loaded,
46
+ root: inspection.projectRoot,
47
+ reason: 'no knowledge entry is loaded',
48
+ ...allowEmptyValve(args, loaded),
49
+ },
50
+ ],
22
51
  details: [
23
52
  ...missing.map((m) => `error: ${m.code} on ${m.entryId}`),
24
53
  ...dup.map((m) => `warn: duplicate id ${m.entryId}`),
25
54
  ],
26
55
  };
27
56
  }
28
- function templatesGroup(inspection) {
57
+ function templatesGroup(inspection, args) {
29
58
  const details = [];
30
59
  let errors = 0;
31
60
  let warnings = 0;
@@ -48,10 +77,19 @@ function templatesGroup(inspection) {
48
77
  passed: errors === 0,
49
78
  errors,
50
79
  warnings,
80
+ ...(inspection.templates.length === 0 ? { examinedNothing: true } : {}),
81
+ // THE record `templates doctor` settles on (zero registered → 2).
82
+ requestCoverage: [
83
+ templateRegistryCoverage({
84
+ total: inspection.templates.length,
85
+ root: inspection.projectRoot,
86
+ acceptance: allowEmptyValve(args, inspection.templates.length),
87
+ }),
88
+ ],
51
89
  details,
52
90
  };
53
91
  }
54
- function pipelinesGroup(inspection) {
92
+ function pipelinesGroup(inspection, args) {
55
93
  const details = [];
56
94
  let errors = 0;
57
95
  let warnings = 0;
@@ -73,22 +111,51 @@ function pipelinesGroup(inspection) {
73
111
  details.push(`warn: pipeline ${p.id} has no description`);
74
112
  }
75
113
  }
114
+ const registered = inspection.pipelines.length;
76
115
  return {
77
116
  name: 'pipelines',
78
117
  passed: errors === 0,
79
118
  errors,
80
119
  warnings,
120
+ ...(registered === 0 ? { examinedNothing: true } : {}),
121
+ requestCoverage: [
122
+ {
123
+ unit: 'pipelines',
124
+ expected: registered,
125
+ examined: registered,
126
+ root: inspection.projectRoot,
127
+ reason: 'no pipeline is registered',
128
+ ...allowEmptyValve(args, registered),
129
+ },
130
+ ],
81
131
  details,
82
132
  };
83
133
  }
84
- function packsGroup(inspection) {
85
- const report = buildPackDoctorReport(inspection);
134
+ async function packsGroup(inspection, args) {
135
+ // THE async doctor `packs doctor` runs — the registry loaders' rejected
136
+ // entries included. The sync builder never saw them, so `check packs` read
137
+ // `OK packs errors=0` where `packs doctor` exited 1 (round 12 review, R12-X2).
138
+ const report = await buildPackDoctorReportAsync(inspection);
139
+ // THE pack-doctor settlement (`packDoctorVerdict`, the one `shrk packs
140
+ // doctor` exits on): zero packs examined nothing (SKIP, never OK), and a
141
+ // compiled build never compared to its source is a real shortfall.
142
+ const verdict = packDoctorVerdict(report, inspection);
143
+ const compiled = report.compiledArtifactCoverage;
144
+ const compiledShortfall = compiled ? coverageShortfall(compiled) : undefined;
86
145
  return {
87
146
  name: 'packs',
88
147
  passed: report.passed,
89
148
  errors: report.summary.errors,
90
149
  warnings: report.summary.warnings,
91
- details: report.issues.map((i) => `${i.severity}: ${i.packageName} ${i.code} ${i.message}`),
150
+ ...(verdict.examinedNothing ? { examinedNothing: true } : {}),
151
+ ...(compiled && compiledShortfall !== undefined ? { coverage: [compiled], notVerified: true } : {}),
152
+ // THE pack-doctor coverage — what `packs doctor` settles on, valve included
153
+ // (zero discovered packs → 2 when `check packs` is asked alone).
154
+ requestCoverage: packDoctorCoverage(report, inspection, allowEmptyValve(args, inspection.packs.discoveredPacks.length)),
155
+ details: [
156
+ ...report.issues.map((i) => `${i.severity}: ${i.packageName} ${i.code} — ${i.message}`),
157
+ ...(compiledShortfall !== undefined ? [`not verified: ${compiledShortfall}`] : []),
158
+ ],
92
159
  };
93
160
  }
94
161
  function actionHintsGroup(inspection) {
@@ -98,39 +165,88 @@ function actionHintsGroup(inspection) {
98
165
  passed: true, // warnings only — they do not fail unless --strict
99
166
  errors: 0,
100
167
  warnings: report.issues.length,
168
+ // Action hints live on knowledge entries: none declared, nothing to lint.
169
+ ...(inspection.knowledgeEntries.length === 0 ? { examinedNothing: true } : {}),
101
170
  details: report.issues.map((i) => `warn: ${i.code} on ${i.entryId}`),
102
171
  };
103
172
  }
104
173
  function doctorGroup(inspection) {
105
174
  const result = runDoctor(inspection);
175
+ // THE doctor settlement (`doctorVerdict`), the reading `shrk doctor`
176
+ // settles its exit on: a setup it could not fully verify (a compiled pack
177
+ // build with no build record) is NOT VERIFIED here too, never OK.
178
+ const settled = doctorVerdict(result);
106
179
  return {
107
180
  name: 'doctor',
108
181
  passed: result.passed,
109
182
  errors: result.summary.errors,
110
183
  warnings: result.summary.warnings,
184
+ ...(result.coverage && result.coverage.length > 0 ? { coverage: result.coverage } : {}),
185
+ ...(settled.verdict === DoctorVerdictKind.NotVerified ? { notVerified: true } : {}),
111
186
  details: result.checks
112
187
  .filter((c) => c.severity === 'error' || c.severity === 'warning')
113
188
  .map((c) => `${c.severity}: ${c.title} — ${c.message}`),
114
189
  };
115
190
  }
116
- function renderReport(args, groups, readinessLine) {
191
+ /**
192
+ * `alone` — the invocation asked for exactly one group (`check packs`). Its
193
+ * request coverage is then the verdict's scope: the group's doctor record, so
194
+ * `check packs` over zero packs is 2 like `packs doctor` (`--allow-empty` → 0).
195
+ */
196
+ function renderReport(args, groups, readinessLine, alone = false) {
117
197
  const totalErrors = groups.reduce((s, g) => s + g.errors, 0);
118
198
  const totalWarnings = groups.reduce((s, g) => s + g.warnings, 0);
119
199
  const passed = totalErrors === 0;
120
200
  const strict = flagBool(args, 'strict');
121
201
  const minScore = flagNumber(args, 'min-score');
202
+ // Settle first, render second: a group that could not verify part of its
203
+ // scope (the doctor over an unrecorded compiled pack build) turns a clean
204
+ // proposed 0 into NOT VERIFIED (2), in text AND JSON. A group with NOTHING
205
+ // to validate is `shrk quality`'s aggregate rule: a deliberate skip by
206
+ // default (SKIP, never OK), required — so NOT VERIFIED — under `--strict`.
207
+ const emptyGroups = groups.filter((g) => g.examinedNothing === true);
208
+ const emptyUnit = {
209
+ knowledge: 'knowledge entries',
210
+ 'action-hints': 'knowledge entries (action hints)',
211
+ };
212
+ const settled = settleVerdict(passed && (!strict || totalWarnings === 0) ? 0 : 1, alone
213
+ ? groups.flatMap((g) => g.requestCoverage ?? g.coverage ?? [])
214
+ : [
215
+ ...groups.flatMap((g) => g.coverage ?? []),
216
+ ...(strict
217
+ ? emptyGroups.map((g) => ({
218
+ unit: emptyUnit[g.name] ?? g.name,
219
+ expected: 0,
220
+ examined: 0,
221
+ subject: g.name,
222
+ reason: 'none declared — nothing to validate (required under --strict)',
223
+ }))
224
+ : []),
225
+ ]);
226
+ const statusOf = (g) => !g.passed ? 'failed' : g.examinedNothing ? 'skipped' : g.notVerified ? 'partial' : 'passed';
122
227
  if (flagBool(args, 'json')) {
123
228
  process.stdout.write(asJson({
124
229
  passed,
125
- groups,
230
+ groups: groups.map((g) => ({ ...g, status: statusOf(g) })),
126
231
  totals: { errors: totalErrors, warnings: totalWarnings },
127
232
  readinessLine,
233
+ exitCode: settled.exit,
234
+ verdict: settled.verdict,
235
+ ...(settled.shortfalls.length > 0 ? { shortfalls: settled.shortfalls } : {}),
236
+ ...(settled.accepted.length > 0 ? { accepted: settled.accepted } : {}),
128
237
  }) + '\n');
129
- return passed && (!strict || totalWarnings === 0) ? 0 : 1;
238
+ return settled.exit;
130
239
  }
131
240
  process.stdout.write(header('Check summary'));
241
+ const glyph = {
242
+ failed: 'FAIL ',
243
+ skipped: 'SKIP ',
244
+ partial: 'PART ',
245
+ passed: 'OK ',
246
+ };
132
247
  for (const g of groups) {
133
- process.stdout.write(` ${g.passed ? 'OK ' : 'FAIL '} ${g.name.padEnd(16)} errors=${g.errors} warnings=${g.warnings}\n`);
248
+ const status = statusOf(g);
249
+ process.stdout.write(` ${glyph[status]} ${g.name.padEnd(16)} errors=${g.errors} warnings=${g.warnings}${status === 'skipped' ? ' (examined nothing)' : ''}\n`);
134
250
  }
135
251
  process.stdout.write(`\nTotals: ${totalErrors} errors, ${totalWarnings} warnings\n`);
136
252
  if (readinessLine)
@@ -148,7 +264,13 @@ function renderReport(args, groups, readinessLine) {
148
264
  }
149
265
  const minOk = minScore === undefined ? true : true; // readiness floor already enforced by shrk doctor; only echo here
150
266
  void minOk;
151
- return passed && (!strict || totalWarnings === 0) ? 0 : 1;
267
+ const line = verdictLine(settled, '');
268
+ if (line)
269
+ process.stdout.write(`\n${line}\n`);
270
+ if (alone && settled.exit === ExitCode.NotVerified && groups.some((g) => g.examinedNothing === true)) {
271
+ process.stdout.write(`Pass --${ALLOW_EMPTY_FLAG} to accept an empty ${groups[0]?.name ?? 'group'} set explicitly.\n`);
272
+ }
273
+ return settled.exit;
152
274
  }
153
275
  // ────────────────────────────────────────────────────────────────────────
154
276
  // Subcommand: imports
@@ -157,8 +279,9 @@ async function checkImports(args) {
157
279
  const cwd = resolveCwd(args);
158
280
  const changedOnly = flagBool(args, 'changed-only');
159
281
  const since = flagString(args, 'since');
282
+ const scoped = changedOnly || since !== undefined;
160
283
  let files;
161
- if (changedOnly || since) {
284
+ if (scoped) {
162
285
  const changed = resolveChangedFiles({
163
286
  projectRoot: cwd,
164
287
  ...(since ? { since } : {}),
@@ -210,11 +333,37 @@ async function checkImports(args) {
210
333
  }
211
334
  return 0;
212
335
  }
336
+ // Settle first, render second. The subject list is THE authority
337
+ // (`importHygieneSubjects`, which `buildImportHygieneReport` scanned): an
338
+ // EMPTY changeset — or one with no .ts/.tsx source — examined nothing, and an
339
+ // unreadable in-scope file was never checked. Either is NOT VERIFIED (2),
340
+ // never "OK"; `--allow-empty` accepts an empty scope explicitly.
341
+ const coverage = {
342
+ ...importHygieneCoverage(report),
343
+ ...((report.filesInScope ?? 0) === 0
344
+ ? {
345
+ reason: scoped
346
+ ? `no changed .ts/.tsx source in the changeset (${files?.length ?? 0} changed file(s))`
347
+ : 'no .ts/.tsx source under the scanned roots',
348
+ }
349
+ : {}),
350
+ root: cwd,
351
+ ...allowEmptyValve(args, report.filesInScope ?? 0),
352
+ };
353
+ const settled = settleVerdict(report.verdict === 'errors' ? 1 : 0, [coverage]);
213
354
  if (flagBool(args, 'json')) {
214
- process.stdout.write(asJson(report) + '\n');
215
- return report.verdict === 'errors' ? 1 : 0;
355
+ process.stdout.write(asJson({
356
+ ...report,
357
+ // The engine word, except when the run settled NOT VERIFIED.
358
+ verdict: settled.exit === ExitCode.NotVerified ? 'not-verified' : report.verdict,
359
+ coverage,
360
+ exitCode: settled.exit,
361
+ shortfalls: settled.shortfalls,
362
+ accepted: settled.accepted,
363
+ }) + '\n');
364
+ return settled.exit;
216
365
  }
217
- process.stdout.write(renderImportHygieneText(report));
366
+ process.stdout.write(renderImportHygieneText(report, settled.exit === ExitCode.NotVerified ? 'NOT VERIFIED' : undefined));
218
367
  // When --fail-on-unexplained-allowlist is set, warn on existing
219
368
  // entries whose reason is still TODO/empty. The scanner has already
220
369
  // un-suppressed them, but we also surface a separate accounting line.
@@ -238,7 +387,13 @@ async function checkImports(args) {
238
387
  }
239
388
  }
240
389
  }
241
- return report.verdict === 'errors' ? 1 : 0;
390
+ const line = verdictLine(settled, '');
391
+ if (line)
392
+ process.stdout.write(`\n${line}\n`);
393
+ if (settled.exit === ExitCode.NotVerified && (report.filesInScope ?? 0) === 0) {
394
+ process.stdout.write(` Pass --${ALLOW_EMPTY_FLAG} to accept an empty changeset explicitly.\n`);
395
+ }
396
+ return settled.exit;
242
397
  }
243
398
  // ────────────────────────────────────────────────────────────────────────
244
399
  // Subcommand: generation
@@ -309,7 +464,81 @@ function readChangedScopeOptions(args, cwd) {
309
464
  out.includeWorktree = true;
310
465
  return out;
311
466
  }
467
+ /**
468
+ * Flags `check boundaries` accepts. Anything else is a typo, never an opt-in:
469
+ * `--rules` / `--diff-agains` used to parse as a silent `true` and the verb ran
470
+ * its bare form at exit 0 (round 11, 3.3#2 / 5.2).
471
+ */
472
+ const BOUNDARY_CHECK_FLAGS = new Set([
473
+ 'rule',
474
+ 'rule-file',
475
+ 'diff-against',
476
+ 'include-comments',
477
+ 'fail-on-dead-units',
478
+ 'no-rule-escalation',
479
+ 'changed-only',
480
+ 'since',
481
+ 'staged',
482
+ 'files',
483
+ 'polyglot',
484
+ 'strict',
485
+ 'json',
486
+ 'fix-suggestions',
487
+ 'watch',
488
+ 'paths',
489
+ 'debounce',
490
+ 'once',
491
+ ALLOW_EMPTY_FLAG,
492
+ // Every dispatcher global (THE list) — a direct handler call may carry them.
493
+ ...GLOBAL_FLAGS,
494
+ ]);
495
+ const BOUNDARIES_CHECK_USAGE = 'shrk check boundaries [--rule <id>] [--rule-file <path> | --diff-against <path>]\n' +
496
+ ' [--changed-only | --since <ref> | --staged | --files a,b] [--no-rule-escalation]\n' +
497
+ ' [--include-comments] [--fail-on-dead-units] [--allow-empty] [--strict]\n' +
498
+ ' [--fix-suggestions] [--polyglot] [--json] [--watch [--paths a,b] [--debounce N] [--once]]\n' +
499
+ ' Evaluate every boundary rule against the import graph (tsconfig aliases resolved).\n' +
500
+ ' --rule <id> evaluate one rule (an unknown id is a usage error)\n' +
501
+ ' --rule-file <path> evaluate ONLY the rules in a candidate file (the boundaryFiles loader)\n' +
502
+ ' --diff-against <path> dry run: violations a candidate rule set would ADD / REMOVE vs the active set\n' +
503
+ ' (--strict does not apply to the diff: it settles on error violations, dead\n' +
504
+ ' candidate scopes and --fail-on-dead-units only; a warning it adds is reported)\n' +
505
+ ' --changed-only … report violations the changeset introduced; a changeset touching a rule\n' +
506
+ ' source, the config or tsconfig ESCALATES those rules to the whole tree\n' +
507
+ ' --no-rule-escalation keep escalated rules out — they are then reported unexamined (exit 2)\n' +
508
+ ' --include-comments read imports from raw text (a commented-out import counts again)\n' +
509
+ ' --fail-on-dead-units a selector unit that matches nothing, or a local expectEmpty marker whose\n' +
510
+ ' target appeared (went live), fails the run (1); a pack marker never does\n' +
511
+ ' --strict warning violations fail too, and so does a local went-live expectEmpty marker\n' +
512
+ ' A unit marked { pattern, expectEmpty: true } whose target does not exist yet is accepted (printed).\n' +
513
+ ' Exit: 0 verified pass · 1 violations / errored rule / stale exception / failOnEmpty rule\n' +
514
+ ' 2 not verified (a rule checked nothing, an empty selection, a suppressed escalation) · 3 usage';
312
515
  async function checkBoundaries(args) {
516
+ // `--help` / `-h` never reaches here: the dispatcher answers it (dispatch/help-intercept.ts).
517
+ const bad = firstUnknownFlag(args, BOUNDARY_CHECK_FLAGS);
518
+ if (bad !== undefined) {
519
+ // THE one refusal format (round 13, lane P) — the dispatcher's own wording.
520
+ const shown = [...BOUNDARY_CHECK_FLAGS].filter((f) => !IMPLICIT_FLAGS.has(f));
521
+ const refusal = unknownFlagRefusal({
522
+ label: 'check boundaries',
523
+ flags: [bad],
524
+ known: shown,
525
+ accepts: shown,
526
+ ...(args.argv !== undefined ? { argv: args.argv } : {}),
527
+ exitCode: ExitCode.UsageError,
528
+ });
529
+ process.stderr.write(refusal.message);
530
+ return refusal.exitCode;
531
+ }
532
+ for (const flag of ['rule', 'rule-file', 'diff-against']) {
533
+ if (args.flags.has(flag) && !flagString(args, flag)) {
534
+ process.stderr.write(`--${flag} needs a value.\n${BOUNDARIES_CHECK_USAGE}\n`);
535
+ return ExitCode.UsageError;
536
+ }
537
+ }
538
+ if (args.flags.has('rule-file') && args.flags.has('diff-against')) {
539
+ process.stderr.write('--rule-file and --diff-against are exclusive: --rule-file evaluates a candidate alone, --diff-against compares it with the active rules.\n');
540
+ return ExitCode.UsageError;
541
+ }
313
542
  const watchExit = await maybeRunInWatchMode(args, checkBoundariesOnce, {
314
543
  defaultPaths: BOUNDARIES_DEFAULT_WATCH_PATHS,
315
544
  });
@@ -325,126 +554,229 @@ const BOUNDARIES_DEFAULT_WATCH_PATHS = [
325
554
  'src',
326
555
  'tools',
327
556
  ];
557
+ /**
558
+ * Load a `--rule-file` / `--diff-against` candidate through THE boundary rule
559
+ * loader (the one `boundaryFiles` use — it transpiles TS on the fly, so a rule
560
+ * edit is testable without a build). A missing file, one that throws, or one
561
+ * with no valid rule is a usage error; invalid rules next to valid ones become
562
+ * errored-rule rows.
563
+ */
564
+ async function loadBoundaryRuleFileArg(cwd, projectRoot, raw, flag) {
565
+ const abs = nodePath.resolve(cwd, raw);
566
+ if (!existsSync(abs))
567
+ return { ok: false, message: `--${flag}: ${abs} does not exist.` };
568
+ const loaded = await loadBoundaryRulesFromFile(abs);
569
+ if (loaded.loadError !== undefined) {
570
+ return { ok: false, message: `--${flag}: ${abs} failed to load — ${loaded.loadError}` };
571
+ }
572
+ const loadIssues = boundaryLoadIssuesFromFile(loaded, projectRoot, 'rule-file');
573
+ if (loaded.rules.length === 0) {
574
+ const detail = loadIssues.map((i) => `${boundaryLoadIssueLabel(i)}: ${i.issues.join('; ')}`).join(' | ');
575
+ return {
576
+ ok: false,
577
+ message: `--${flag}: ${abs} defines no valid boundary rule${detail ? ` (${detail})` : ''}.`,
578
+ };
579
+ }
580
+ return { ok: true, path: abs, rules: loaded.rules, loadIssues };
581
+ }
582
+ /** The envelope row an errored (never-evaluated) boundary rule becomes. */
583
+ function boundaryLoadIssueRow(issue) {
584
+ return {
585
+ id: boundaryLoadIssueLabel(issue),
586
+ type: 'boundary',
587
+ status: 'error',
588
+ severity: 'error',
589
+ counts: {},
590
+ violations: [],
591
+ error: `${issue.file}: ${issue.kind} — ${issue.issues.join('; ')}`,
592
+ coverage: boundaryLoadIssueCoverage(issue),
593
+ };
594
+ }
595
+ /** One envelope row per selected rule (+ one errored row per load issue). */
596
+ function boundaryGateRules(result) {
597
+ return [
598
+ ...result.rules.map((r) => ({
599
+ id: r.ruleId,
600
+ type: 'boundary',
601
+ status: r.status,
602
+ severity: r.severity === 'error' ? 'error' : 'warning',
603
+ counts: {
604
+ filesInScope: r.detail.filesInScope,
605
+ edgesInScope: r.detail.edgesInScope,
606
+ violations: r.violations.length,
607
+ suppressed: r.detail.suppressed,
608
+ },
609
+ violations: [
610
+ ...r.violations.map((v) => ({
611
+ id: v.importSpecifier,
612
+ file: v.file,
613
+ line: v.line,
614
+ message: v.message,
615
+ ...(v.suggestedFix ? { hint: v.suggestedFix } : {}),
616
+ })),
617
+ ...r.staleExceptions.map((s) => ({ id: `exceptions[${s.index}]`, file: s.file, message: s.message })),
618
+ ],
619
+ ...(r.skipReason ? { skipReason: r.skipReason } : {}),
620
+ coverage: r.coverage,
621
+ // Round 13: the rule's expectEmpty acceptance (record B) — folded by the
622
+ // envelope into the ONE settle, the same records the orchestrator
623
+ // settled (`boundaryCheckCoverage`) — and its unit lines.
624
+ ...(r.detail.unitAcceptance ? { unitAcceptance: r.detail.unitAcceptance } : {}),
625
+ ...(r.detail.units ? { units: r.detail.units } : {}),
626
+ })),
627
+ ...result.loadIssues.map(boundaryLoadIssueRow),
628
+ ];
629
+ }
630
+ /** The polyglot engine's contribution to a `--polyglot` run (its own section + one envelope row). */
631
+ function buildPolyglotSection(cwd, changedScope) {
632
+ const report = buildPolyglotBoundaryReport({ projectRoot: cwd });
633
+ const filtered = changedScope ? filterViolationsToChangedScope(report.violations, changedScope) : null;
634
+ const violations = filtered ? filtered.includedViolations : report.violations;
635
+ const errors = violations.filter((v) => v.severity === 'error').length;
636
+ const warnings = violations.filter((v) => v.severity === 'warning').length;
637
+ // No polyglot language detected → the engine evaluated nothing: no row (a
638
+ // row would claim an evaluation), and the run's coverage says so.
639
+ const rows = report.counts.rules > 0
640
+ ? [
641
+ {
642
+ id: 'polyglot',
643
+ type: 'boundary',
644
+ status: errors > 0 ? 'failed' : 'passed',
645
+ severity: 'error',
646
+ counts: { rules: report.counts.rules, edges: report.counts.edges, violations: violations.length },
647
+ violations: violations.map((v) => ({
648
+ id: v.importSpecifier,
649
+ file: v.fromFile,
650
+ message: `[${v.ruleId}] ${v.suggestedFix}`,
651
+ })),
652
+ coverage: { unit: 'polyglot rules', expected: report.counts.rules, examined: report.counts.rules },
653
+ },
654
+ ]
655
+ : [];
656
+ return { report, filtered, violations, errors, warnings, rows };
657
+ }
658
+ /**
659
+ * The exit-0 sentence handed to `verdictLine`. The `✓` form ONLY when nothing
660
+ * was reported above: a warning / info violation or a dead selector unit (a
661
+ * pattern that can never fire — the rule it sits in enforces less than it
662
+ * says) is named on the verdict line instead, never followed by a ✓ —
663
+ * policy-lint's convention for warnings.
664
+ */
665
+ function boundaryCleanSentence(result, polyglotWarnings) {
666
+ const scope = result.changed ? ' introduced by changed files' : '';
667
+ const warnings = result.counts.warning + polyglotWarnings;
668
+ const info = result.counts.info;
669
+ const dead = result.deadUnits.length;
670
+ // Round 13: a LOCAL went-live expectEmpty marker is drift — reported above,
671
+ // never under a ✓. A PACK marker that went live is INFO (its own block above
672
+ // says so: the consumer cannot edit it), so it withholds nothing — the
673
+ // sibling verdicts (`gates coverage`, the asset doctors) count local markers
674
+ // only. An intended-empty unit is not drift: its acceptance prints below the ✓.
675
+ const wentLive = result.wentLive.filter((u) => u.packageName === undefined).length;
676
+ if (warnings === 0 && info === 0 && dead === 0 && wentLive === 0) {
677
+ return `Verdict: OK — no boundary violations${scope}. ✓`;
678
+ }
679
+ const parts = [];
680
+ if (warnings > 0)
681
+ parts.push(`${warnings} warning(s)`);
682
+ if (info > 0)
683
+ parts.push(`${info} info finding(s)`);
684
+ if (dead > 0)
685
+ parts.push(`${dead} dead selector unit(s)`);
686
+ const lead = warnings + info > 0 ? `no blocking boundary violations${scope}` : `no boundary violations${scope}`;
687
+ const clauses = [];
688
+ if (parts.length > 0) {
689
+ const hint = dead > 0 ? ' (--fail-on-dead-units to fail on dead units)' : '';
690
+ clauses.push(`${parts.join(', ')} reported above${hint}`);
691
+ }
692
+ if (wentLive > 0)
693
+ clauses.push(`${wentLive} expectEmpty unit(s) went live (remove each stale expectEmpty marker)`);
694
+ return `Verdict: ${lead} — ${clauses.join('; ')}.`;
695
+ }
696
+ /** The lead sentence of a NOT VERIFIED boundary verdict — says WHY nothing (or not everything) was proved. */
697
+ function boundaryNotVerifiedLead(result, env) {
698
+ if (result.rulesConfigured === 0 && result.loadIssues.length === 0) {
699
+ return 'Verdict: NOT VERIFIED — 0 boundary rules loaded, so nothing was checked.';
700
+ }
701
+ if (result.changed && (result.changed.escalationSuppressed.length ?? 0) > 0) {
702
+ return `Verdict: NOT VERIFIED — the changeset edits the definition of ${result.changed.escalationSuppressed.length} rule(s) and --no-rule-escalation kept them out: changed-only cannot see a rule edit.`;
703
+ }
704
+ if (result.changed && env.coverage.expected === 0) {
705
+ return 'Verdict: NOT VERIFIED — no changed source file is governed by a boundary rule; nothing was checked.';
706
+ }
707
+ const skipped = boundaryRulesCheckedNothing(result);
708
+ const first = skipped[0];
709
+ if (first) {
710
+ return `Verdict: NOT VERIFIED — ${skipped.length} of ${result.rules.length} rules checked nothing (${first.ruleId}: ${first.skipReason ?? 'no governed file'})`;
711
+ }
712
+ return "Verdict: NOT VERIFIED — part of a rule's scope was never examined.";
713
+ }
328
714
  async function checkBoundariesOnce(args) {
329
715
  const cwd = resolveCwd(args);
716
+ const wantJson = flagBool(args, 'json');
330
717
  const inspection = await inspectSharkcraft({ cwd });
331
- const rules = inspection.boundaryRegistry.list();
332
718
  const ruleFilter = flagString(args, 'rule');
333
- const polyglot = flagBool(args, 'polyglot');
334
- const changedScope = readChangedScopeOptions(args, cwd);
335
- if (polyglot) {
336
- // Emit the polyglot boundary report alongside (or instead of) the
337
- // existing TS-aware engine. Default is `both` — append polyglot section
338
- // after the TS one. JSON mode returns a combined payload.
339
- const { buildPolyglotBoundaryReport } = await import('@shrkcrft/inspector');
340
- const polyglotReport = buildPolyglotBoundaryReport({ projectRoot: cwd });
341
- // Apply changed-only filter before emit.
342
- const polyglotFiltered = changedScope
343
- ? filterViolationsToChangedScope(polyglotReport.violations, changedScope)
344
- : null;
345
- const polyglotViolations = polyglotFiltered
346
- ? polyglotFiltered.includedViolations
347
- : polyglotReport.violations;
348
- const polyglotErrors = polyglotViolations.filter((v) => v.severity === 'error').length;
349
- const polyglotWarnings = polyglotViolations.filter((v) => v.severity === 'warning').length;
350
- if (flagBool(args, 'json')) {
351
- // We still run the TS engine when rules exist, but always tack on the
352
- // polyglot report so CI/integrators get both surfaces.
353
- const tsScan = rules.length > 0 ? scanImports({ projectRoot: cwd }) : null;
354
- const tsEval = tsScan ? evaluateBoundaries(tsScan, rules, {
355
- ...(ruleFilter ? { onlyRuleId: ruleFilter } : {}),
356
- }) : null;
357
- const tsFiltered = tsEval && changedScope
358
- ? filterViolationsToChangedScope(tsEval.violations, changedScope)
359
- : null;
360
- const tsViolations = tsFiltered ? tsFiltered.includedViolations : tsEval?.violations ?? [];
361
- const tsErrors = tsViolations.filter((v) => v.severity === 'error').length;
362
- process.stdout.write(asJson({
363
- polyglot: {
364
- counts: { errors: polyglotErrors, warnings: polyglotWarnings, rules: polyglotReport.counts.rules },
365
- languages: polyglotReport.languages,
366
- violations: polyglotViolations,
367
- },
368
- typescript: tsEval ? {
369
- counts: { ...tsEval.counts, error: tsErrors },
370
- violations: tsViolations,
371
- } : null,
372
- ...(changedScope
373
- ? {
374
- changedScope: {
375
- mode: polyglotFiltered?.mode ?? ChangedScopeMode.ChangedOnly,
376
- changedFiles: polyglotFiltered?.changedFiles ?? [],
377
- ignoredLegacyCount: (polyglotFiltered?.ignoredLegacyCount ?? 0) + (tsFiltered?.ignoredLegacyCount ?? 0),
378
- ignoredLegacyByRule: {
379
- ...(polyglotFiltered?.ignoredLegacyByRule ?? {}),
380
- ...(tsFiltered?.ignoredLegacyByRule ?? {}),
381
- },
382
- },
383
- }
384
- : {}),
385
- }) + '\n');
386
- return polyglotErrors > 0 || tsErrors > 0 ? 1 : 0;
387
- }
388
- process.stdout.write(header('Polyglot boundaries'));
389
- process.stdout.write(kv('languages', polyglotReport.languages.join(', ') || '(none)') + '\n');
390
- process.stdout.write(kv('rules', String(polyglotReport.counts.rules)) + '\n');
391
- process.stdout.write(kv('violations', `${polyglotErrors} errors, ${polyglotWarnings} warnings`) + '\n');
392
- if (polyglotFiltered) {
393
- process.stdout.write(kv('mode', polyglotFiltered.mode) + '\n');
394
- process.stdout.write(kv('changed files', String(polyglotFiltered.changedFiles.length)) + '\n');
395
- process.stdout.write(kv('legacy ignored', String(polyglotFiltered.ignoredLegacyCount)) + '\n');
396
- }
397
- if (polyglotViolations.length > 0) {
398
- for (const v of polyglotViolations) {
399
- process.stdout.write(` ${v.severity.toUpperCase().padEnd(8)} ${v.ruleId} ${v.fromFile}\n`);
400
- process.stdout.write(` import: "${v.importSpecifier}"\n`);
401
- process.stdout.write(` ↳ ${v.suggestedFix}\n`);
402
- }
403
- }
404
- else if (polyglotFiltered) {
405
- process.stdout.write(' No boundary violations introduced by changed files.\n');
406
- }
407
- if (polyglotErrors > 0)
408
- return 1;
409
- process.stdout.write('\n');
410
- // Fall through to the TS engine below so callers see both views.
411
- }
412
- if (rules.length === 0) {
413
- if (flagBool(args, 'json')) {
414
- process.stdout.write(asJson({ rules: 0, violations: [], note: 'no boundary rules configured' }) + '\n');
415
- return 0;
719
+ const diffAgainst = flagString(args, 'diff-against');
720
+ if (diffAgainst)
721
+ return checkBoundaryRuleDiff(args, inspection, diffAgainst);
722
+ let ruleFile;
723
+ const ruleFileArg = flagString(args, 'rule-file');
724
+ if (ruleFileArg) {
725
+ const loaded = await loadBoundaryRuleFileArg(cwd, inspection.projectRoot, ruleFileArg, 'rule-file');
726
+ if (!loaded.ok) {
727
+ process.stderr.write(`${loaded.message}\n`);
728
+ return ExitCode.UsageError;
416
729
  }
417
- process.stdout.write(header('Boundaries'));
418
- process.stdout.write(' No boundary rules configured. Add `sharkcraft/boundaries.ts` or install a pack with `boundaryFiles`.\n');
419
- return 0;
730
+ ruleFile = loaded;
420
731
  }
421
- const scan = scanImports({ projectRoot: cwd });
422
- const tsconfigPaths = loadTsconfigPaths(cwd);
423
- const evalResultRaw = evaluateBoundaries(scan, rules, {
732
+ const changedScope = readChangedScopeOptions(args, cwd);
733
+ // THE boundary orchestrator — the same call finish, diff-check, quality and
734
+ // both MCP boundary tools make, so none of them can disagree with this.
735
+ const result = runBoundaryCheck(inspection, {
424
736
  ...(ruleFilter ? { onlyRuleId: ruleFilter } : {}),
425
- ...(tsconfigPaths.aliases.size > 0 ? { tsconfigPaths } : {}),
737
+ ...(ruleFile ? { ruleFile } : {}),
738
+ ...(changedScope ? { changedScope } : {}),
739
+ includeComments: flagBool(args, 'include-comments'),
740
+ escalate: !flagBool(args, 'no-rule-escalation'),
741
+ failOnDeadUnits: flagBool(args, 'fail-on-dead-units'),
742
+ strict: flagBool(args, 'strict'),
426
743
  });
427
- // Changed-only filtering on the TS engine output.
428
- const changedFiltered = changedScope
429
- ? filterViolationsToChangedScope(evalResultRaw.violations, changedScope)
430
- : null;
431
- const evalResult = changedFiltered
432
- ? {
433
- ...evalResultRaw,
434
- violations: changedFiltered.includedViolations,
435
- counts: {
436
- error: changedFiltered.includedViolations.filter((v) => v.severity === 'error').length,
437
- warning: changedFiltered.includedViolations.filter((v) => v.severity === 'warning').length,
438
- info: changedFiltered.includedViolations.filter((v) => v.severity === 'info').length,
439
- },
744
+ if (result.unknownRuleId !== undefined) {
745
+ const ids = result.availableRuleIds ?? [];
746
+ const env = buildGateEnvelope('check boundaries', ExitCode.UsageError, [], result.runCoverage);
747
+ if (wantJson) {
748
+ process.stdout.write(asJson({
749
+ schema: result.schema,
750
+ ok: false,
751
+ error: 'unknown-rule',
752
+ ruleId: result.unknownRuleId,
753
+ available: ids,
754
+ verdict: planeVerdictForExit(env.exit),
755
+ exitCode: env.exit,
756
+ gate: env,
757
+ }) + '\n');
758
+ return env.exit;
440
759
  }
441
- : evalResultRaw;
442
- const summary = summarizeImports(scan);
443
- const strict = flagBool(args, 'strict');
444
- const failed = evalResult.counts.error > 0 || (strict && evalResult.counts.warning > 0);
760
+ process.stderr.write(`No boundary rule "${result.unknownRuleId}". ${ruleFile ? `Rules in ${ruleFile.path}` : 'Configured rules'}: ${ids.length > 0 ? ids.join(', ') : '(none)'}\n`);
761
+ return env.exit;
762
+ }
763
+ // Settle first, render second: one envelope for text AND JSON.
764
+ const polyglot = flagBool(args, 'polyglot') ? buildPolyglotSection(cwd, changedScope) : undefined;
765
+ const polyRows = polyglot?.rows.length ?? 0;
766
+ const ruleResults = [...boundaryGateRules(result), ...(polyglot?.rows ?? [])];
767
+ const expected = result.runCoverage.expected + polyRows;
768
+ const runCoverage = {
769
+ ...result.runCoverage,
770
+ expected,
771
+ examined: result.runCoverage.examined + polyRows,
772
+ ...allowEmptyValve(args, expected),
773
+ };
774
+ const proposed = polyglot && polyglot.errors > 0 ? ExitCode.Failure : result.proposedExit;
775
+ const env = buildGateEnvelope('check boundaries', proposed, ruleResults, runCoverage);
776
+ const exit = env.exit;
445
777
  const wantFixSuggestions = flagBool(args, 'fix-suggestions');
446
778
  const fixSuggestions = wantFixSuggestions
447
- ? suggestBoundaryFixes(inspection, evalResult.violations.map((v) => ({
779
+ ? suggestBoundaryFixes(inspection, result.violations.map((v) => ({
448
780
  ruleId: v.ruleId,
449
781
  file: v.file,
450
782
  line: v.line,
@@ -452,63 +784,223 @@ async function checkBoundariesOnce(args) {
452
784
  ...(v.suggestedFix ? { suggestedFix: v.suggestedFix } : {}),
453
785
  })))
454
786
  : [];
455
- if (flagBool(args, 'json')) {
787
+ const suppressedExempt = result.suppressed.filter((s) => s.reason === 'exempt-file').length;
788
+ const suppressedException = result.suppressed.length - suppressedExempt;
789
+ // THE predicate (`boundaryRuleCheckedNothing`, via the orchestrator's
790
+ // helpers MCP `check_boundaries` and `diff-check` read too): a failOnEmpty
791
+ // rule's row is `failed`, yet it checked nothing and is never counted as
792
+ // evaluated; a rule whose governed files went unread is PARTIAL, never
793
+ // listed as "checked nothing".
794
+ const skippedRules = boundaryRulesCheckedNothing(result);
795
+ const evaluatedRules = boundaryRulesEvaluated(result);
796
+ // Round 13 (K6): a rule accepted as intended-empty examined 0 files — THE
797
+ // count above leaves it out, and it is printed apart, never dropped.
798
+ const acceptedEmptyRules = boundaryRulesAcceptedEmpty(result);
799
+ if (wantJson) {
456
800
  process.stdout.write(asJson({
457
- passed: !failed,
458
- rulesEvaluated: evalResult.rulesEvaluated,
459
- edgesEvaluated: evalResult.edgesEvaluated,
460
- counts: evalResult.counts,
461
- violations: evalResult.violations,
462
- importGraph: summary,
801
+ schema: result.schema,
802
+ passed: exit === ExitCode.VerifiedPass,
803
+ verdict: planeVerdictForExit(exit, result.counts.warning > 0 ? 'warnings' : undefined),
804
+ exitCode: exit,
805
+ ruleSource: result.ruleSource,
806
+ rulesConfigured: result.rulesConfigured,
807
+ rulesSelected: result.selectedRuleIds.length,
808
+ // Rules that examined at least one governed file — never a skipped one,
809
+ // never one accepted as intended-empty (counted in rulesAcceptedEmpty).
810
+ rulesEvaluated: evaluatedRules,
811
+ rulesAcceptedEmpty: acceptedEmptyRules.length,
812
+ edgesEvaluated: result.scan.edges.length,
813
+ zone: result.scan.zone ?? 'code',
814
+ counts: result.counts,
815
+ violations: result.violations,
816
+ suppressed: result.suppressed,
817
+ suppressedCounts: { exemptFile: suppressedExempt, exception: suppressedException },
818
+ staleExceptions: result.staleExceptions,
819
+ skipped: boundarySkippedRuleRows(result),
820
+ deadUnits: result.deadUnits,
821
+ // Round 13: every expectEmpty unit's state — accepted (intended empty),
822
+ // stale (went live) — the units that fail this run, and the settled
823
+ // acceptances (at exit 0; `gate.accepted` carries the same lines).
824
+ intendedEmpty: result.intendedEmpty,
825
+ wentLive: result.wentLive,
826
+ failingUnits: result.failingUnits,
827
+ accepted: env.accepted,
828
+ coverage: result.rules.map((r) => r.detail),
829
+ // Files the scan matched but could not read — each is folded into the
830
+ // coverage of every rule whose scope it is in (never "examined").
831
+ unreadFiles: result.scan.unread ?? [],
832
+ loadIssues: result.loadIssues,
833
+ ...(result.configuration ? { configuration: result.configuration } : {}),
834
+ ...(result.rulesConfigured === 0 && result.loadIssues.length === 0
835
+ ? { rules: 0, note: 'no boundary rules configured' }
836
+ : {}),
837
+ importGraph: summarizeImports(result.scan),
463
838
  ...(wantFixSuggestions ? { fixSuggestions } : {}),
464
- ...(changedFiltered
839
+ ...(result.changed
465
840
  ? {
466
841
  changedScope: {
467
- mode: changedFiltered.mode,
468
- changedFiles: changedFiltered.changedFiles,
469
- includedViolations: changedFiltered.includedViolations,
470
- ignoredLegacyCount: changedFiltered.ignoredLegacyCount,
471
- ignoredLegacyByRule: changedFiltered.ignoredLegacyByRule,
842
+ mode: result.changed.mode,
843
+ changedFiles: result.changed.changedFiles,
844
+ governedFiles: result.changed.governedFiles,
845
+ includedViolations: result.violations,
846
+ ignoredLegacyCount: result.changed.ignoredLegacyCount,
847
+ ignoredLegacyByRule: result.changed.ignoredLegacyByRule,
848
+ escalation: {
849
+ ruleIds: result.changed.escalatedRuleIds,
850
+ reasons: result.changed.escalation.reasons,
851
+ },
852
+ escalationSuppressed: result.changed.escalationSuppressed,
853
+ },
854
+ }
855
+ : {}),
856
+ ...(polyglot
857
+ ? {
858
+ polyglot: {
859
+ counts: { errors: polyglot.errors, warnings: polyglot.warnings, rules: polyglot.report.counts.rules },
860
+ languages: polyglot.report.languages,
861
+ violations: polyglot.violations,
472
862
  },
473
863
  }
474
864
  : {}),
865
+ gate: env,
475
866
  }) + '\n');
476
- return failed ? 1 : 0;
867
+ return exit;
477
868
  }
478
- process.stdout.write(header('Boundaries'));
479
- process.stdout.write(kv('rules', String(evalResult.rulesEvaluated)) + '\n');
480
- process.stdout.write(kv('files scanned', String(summary.filesScanned)) + '\n');
481
- process.stdout.write(kv('imports', String(summary.totalImports)) + '\n');
482
- process.stdout.write(kv('violations', `${evalResult.counts.error} errors, ${evalResult.counts.warning} warnings, ${evalResult.counts.info} info`) + '\n');
483
- if (changedFiltered) {
484
- process.stdout.write(kv('mode', changedFiltered.mode) + '\n');
485
- process.stdout.write(kv('changed files', String(changedFiltered.changedFiles.length)) + '\n');
486
- process.stdout.write(kv('legacy ignored', String(changedFiltered.ignoredLegacyCount)) + '\n');
869
+ if (polyglot) {
870
+ process.stdout.write(header('Polyglot boundaries'));
871
+ process.stdout.write(kv('languages', polyglot.report.languages.join(', ') || '(none)') + '\n');
872
+ process.stdout.write(kv('rules', String(polyglot.report.counts.rules)) + '\n');
873
+ process.stdout.write(kv('violations', `${polyglot.errors} errors, ${polyglot.warnings} warnings`) + '\n');
874
+ if (polyglot.filtered) {
875
+ process.stdout.write(kv('mode', polyglot.filtered.mode) + '\n');
876
+ process.stdout.write(kv('changed files', String(polyglot.filtered.changedFiles.length)) + '\n');
877
+ process.stdout.write(kv('legacy ignored', String(polyglot.filtered.ignoredLegacyCount)) + '\n');
878
+ }
879
+ if (polyglot.report.counts.rules === 0) {
880
+ process.stdout.write(' (no polyglot language detected — the polyglot engine evaluated nothing)\n');
881
+ }
882
+ for (const v of polyglot.violations) {
883
+ process.stdout.write(` ${v.severity.toUpperCase().padEnd(8)} ${v.ruleId} ${v.fromFile}\n`);
884
+ process.stdout.write(` import: "${v.importSpecifier}"\n`);
885
+ process.stdout.write(` ↳ ${v.suggestedFix}\n`);
886
+ }
887
+ process.stdout.write('\n');
487
888
  }
488
- process.stdout.write('\n');
489
- if (evalResult.violations.length === 0) {
490
- if (changedFiltered) {
491
- process.stdout.write('No boundary violations introduced by changed files.\n');
889
+ process.stdout.write(header('Boundaries'));
890
+ const ruleParts = [
891
+ `${result.rulesConfigured} configured${result.ruleSource.kind === 'rule-file' ? ` in ${result.ruleSource.path} (--rule-file)` : ''}`,
892
+ ];
893
+ if (result.changed)
894
+ ruleParts.push(`${result.selectedRuleIds.length} in the changed scope`);
895
+ ruleParts.push(`${evaluatedRules} evaluated`);
896
+ if (acceptedEmptyRules.length > 0)
897
+ ruleParts.push(`${acceptedEmptyRules.length} accepted as intended-empty`);
898
+ if (skippedRules.length > 0)
899
+ ruleParts.push(`${skippedRules.length} checked nothing`);
900
+ if (result.loadIssues.length > 0)
901
+ ruleParts.push(`${result.loadIssues.length} errored (NOT evaluated)`);
902
+ process.stdout.write(kv('rules', ruleParts.join(', ')) + '\n');
903
+ process.stdout.write(kv('files scanned', String(result.scan.filesScanned)) + '\n');
904
+ process.stdout.write(kv('imports', `${result.scan.edges.length}${result.scan.zone === 'all' ? ' (raw text — comments included)' : ''}`) + '\n');
905
+ process.stdout.write(kv('violations', `${result.counts.error} errors, ${result.counts.warning} warnings, ${result.counts.info} info` +
906
+ (result.suppressed.length > 0
907
+ ? `, ${result.suppressed.length} suppressed (${suppressedExempt} exempt-file, ${suppressedException} exception)`
908
+ : '')) + '\n');
909
+ if (result.changed) {
910
+ process.stdout.write(kv('mode', result.changed.mode) + '\n');
911
+ process.stdout.write(kv('changed files', `${result.changed.changedFiles.length} (${result.changed.governedFiles.length} governed by a rule)`) + '\n');
912
+ process.stdout.write(kv('legacy ignored', String(result.changed.ignoredLegacyCount)) + '\n');
913
+ if (result.changed.escalatedRuleIds.length > 0) {
914
+ const why = result.changed.escalation.reasons.map((r) => `${r.file} changed (${r.kind})`).join('; ');
915
+ process.stdout.write(kv('escalated', `${result.changed.escalatedRuleIds.length} rule(s) — ${why}: evaluated against the whole tree`) + '\n');
492
916
  }
493
- else {
494
- process.stdout.write('No violations.\n');
917
+ if (result.changed.escalationSuppressed.length > 0) {
918
+ process.stdout.write(kv('escalation', `SUPPRESSED by --no-rule-escalation for ${result.changed.escalationSuppressed.length} rule(s) — the violations their edit created are NOT examined`) + '\n');
495
919
  }
496
- return 0;
497
920
  }
498
- for (const v of evalResult.violations) {
921
+ const config = result.configuration;
922
+ if (config &&
923
+ (!config.configured || config.unlistedDefaultFile !== undefined || config.missingListedFiles.length > 0)) {
924
+ process.stdout.write(kv('sharkcraft dir', config.sharkcraftDir ?? '(none found)') + '\n');
925
+ for (const d of config.diagnostics)
926
+ process.stdout.write(` ! ${d}\n`);
927
+ }
928
+ process.stdout.write('\n');
929
+ for (const v of result.violations) {
499
930
  const tag = v.severity.toUpperCase().padEnd(8);
500
931
  process.stdout.write(` ${tag} ${v.ruleId.padEnd(28)} ${v.file}:${v.line}\n`);
501
932
  process.stdout.write(` import: "${v.importSpecifier}"\n`);
502
- if (v.matchedForbidden) {
933
+ if (v.matchedForbidden && v.matchKind === 'subpath') {
934
+ process.stdout.write(` matched forbidden package: ${v.matchedForbidden} (subpath import — bare patterns cover subpaths; set forbiddenMatch: 'exact' for entrypoint-only)\n`);
935
+ }
936
+ else if (v.matchedForbidden) {
503
937
  process.stdout.write(` matched forbidden pattern: ${v.matchedForbidden}\n`);
504
938
  }
505
939
  else if (v.notAllowed) {
506
940
  process.stdout.write(` not in allowed list for ${v.ruleId}\n`);
507
941
  }
942
+ if (v.resolvedVia)
943
+ process.stdout.write(` resolved via tsconfig alias: ${v.resolvedVia}\n`);
508
944
  process.stdout.write(` ${v.message}\n`);
509
945
  if (v.suggestedFix)
510
946
  process.stdout.write(` ↳ ${v.suggestedFix}\n`);
511
947
  }
948
+ for (const issue of result.loadIssues) {
949
+ const what = issue.kind === 'invalid-rule'
950
+ ? `rule '${issue.ruleId ?? `#${issue.index ?? '?'}`}' failed validation`
951
+ : issue.kind === 'missing-file'
952
+ ? 'listed rule file does not exist'
953
+ : 'rule file failed to load';
954
+ process.stdout.write(` ERROR ${issue.file}: ${what} — NOT evaluated\n`);
955
+ for (const line of issue.issues)
956
+ process.stdout.write(` ${line}\n`);
957
+ }
958
+ for (const s of result.staleExceptions) {
959
+ process.stdout.write(` ERROR ${s.ruleId.padEnd(28)} ${s.file}\n`);
960
+ process.stdout.write(` stale exception: ${s.message}\n`);
961
+ }
962
+ for (const r of skippedRules) {
963
+ process.stdout.write(` – ${r.ruleId} checked nothing — ${r.skipReason ?? 'no governed file'}${r.failedOnEmpty ? ' (failOnEmpty: counts as a failure)' : ''}\n`);
964
+ }
965
+ for (const r of env.rules.filter((x) => x.status === 'partial')) {
966
+ process.stdout.write(` ~ ${r.id} PARTIAL — ${r.shortfall ?? 'part of its scope was never examined'}\n`);
967
+ }
968
+ if (result.deadUnits.length > 0) {
969
+ process.stdout.write(`\nDead selector units (${result.deadUnits.length}) — each matches nothing, so it enforces nothing:\n`);
970
+ for (const d of result.deadUnits) {
971
+ process.stdout.write(` • [${d.unit}] ${d.ruleId}: ${d.selector} — ${d.reason}\n`);
972
+ }
973
+ if (!flagBool(args, 'fail-on-dead-units')) {
974
+ process.stdout.write(' Pass --fail-on-dead-units to fail the run on them.\n');
975
+ }
976
+ }
977
+ // Round 13: a marked unit whose target appeared — the fence went live, the
978
+ // expectEmpty marker is stale. A LOCAL marker is drift, not a pass: ✓
979
+ // withheld; it fails under --fail-on-dead-units / --strict. A PACK marker is
980
+ // INFO in its own block — never a failure, and never the consumer's to remove
981
+ // (the pack's author drops it in a release).
982
+ const localWentLive = result.wentLive.filter((u) => u.packageName === undefined);
983
+ const packWentLive = result.wentLive.filter((u) => u.packageName !== undefined);
984
+ if (localWentLive.length > 0) {
985
+ process.stdout.write(`\nexpectEmpty markers that went live (${localWentLive.length}) — each fence now has a target; remove the marker:\n`);
986
+ for (const u of localWentLive) {
987
+ process.stdout.write(` • [${u.unit}] ${u.ruleId}: ${u.selector} — ${u.reason}\n`);
988
+ }
989
+ }
990
+ if (packWentLive.length > 0) {
991
+ process.stdout.write(`\nINFO — pack expectEmpty markers that went live (${packWentLive.length}) — the fence now has a target; the pack's author removes the marker, and it never fails this run:\n`);
992
+ for (const u of packWentLive) {
993
+ process.stdout.write(` • [${u.unit}] ${u.ruleId}: ${u.selector} — ${u.reason}\n`);
994
+ }
995
+ }
996
+ // R12-5.6 — INFO, never a finding (no verdict, no dead unit): a forbidden
997
+ // pattern a sibling already covers, e.g. the `pkg` + `pkg/**` helper a bare
998
+ // pattern used to need. The engine's coverage is the one answer.
999
+ const redundant = result.rules.flatMap((r) => r.detail.forbidden.flatMap((f) => f.subsumedBy !== undefined ? [{ ruleId: r.ruleId, pattern: f.pattern, by: f.subsumedBy }] : []));
1000
+ if (redundant.length > 0) {
1001
+ const e = redundant[0];
1002
+ process.stdout.write(`\nnote: ${redundant.length} forbidden pattern(s) already covered by a sibling pattern of the same rule — redundant, safe to delete (e.g. ${e.ruleId}: '${e.pattern}' is covered by '${e.by}'); \`shrk boundaries explain <ruleId>\` lists them.\n`);
1003
+ }
512
1004
  if (wantFixSuggestions && fixSuggestions.length > 0) {
513
1005
  process.stdout.write('\nFix suggestions:\n');
514
1006
  for (const s of fixSuggestions) {
@@ -517,14 +1009,236 @@ async function checkBoundariesOnce(args) {
517
1009
  process.stdout.write(` ↳ ${sug}\n`);
518
1010
  }
519
1011
  }
520
- process.stdout.write(`\nVerdict: ${failed ? 'boundary violations need attention' : 'OK'}\n`);
521
- return failed ? 1 : 0;
1012
+ // The final line comes from the SETTLED verdict — the sentence only at 0.
1013
+ if (exit === ExitCode.Failure) {
1014
+ const parts = [];
1015
+ if (result.counts.error > 0)
1016
+ parts.push(`${result.counts.error} error violation(s)`);
1017
+ if (flagBool(args, 'strict') && result.counts.warning > 0)
1018
+ parts.push(`${result.counts.warning} warning(s) under --strict`);
1019
+ if (polyglot && polyglot.errors > 0)
1020
+ parts.push(`${polyglot.errors} polyglot error(s)`);
1021
+ if (result.loadIssues.length > 0)
1022
+ parts.push(`${result.loadIssues.length} errored rule(s)`);
1023
+ if (result.staleExceptions.length > 0)
1024
+ parts.push(`${result.staleExceptions.length} stale exception(s)`);
1025
+ const onEmpty = skippedRules.filter((r) => r.failedOnEmpty).length;
1026
+ if (onEmpty > 0)
1027
+ parts.push(`${onEmpty} rule(s) matched nothing (failOnEmpty)`);
1028
+ // THE units that fail this run (core `selectorUnitFails`, settled by the
1029
+ // orchestrator): an unmarked dead unit under --fail-on-dead-units, a local
1030
+ // went-live expectEmpty marker under it or --strict.
1031
+ const failingDead = result.failingUnits.filter((u) => u.state === UnitLivenessState.Dead).length;
1032
+ const failingLive = result.failingUnits.filter((u) => u.state === UnitLivenessState.WentLive).length;
1033
+ if (failingDead > 0)
1034
+ parts.push(`${failingDead} dead unit(s) (--fail-on-dead-units)`);
1035
+ if (failingLive > 0) {
1036
+ const why = flagBool(args, 'fail-on-dead-units') ? '--fail-on-dead-units' : '--strict';
1037
+ parts.push(`${failingLive} went-live expectEmpty unit(s) (${why})`);
1038
+ }
1039
+ // Round 13 (V1-U6): "violations" only when there are violations — a stale
1040
+ // exception or a dead unit over zero violations is the CHECK's attention.
1041
+ const violated = result.counts.error > 0 || (flagBool(args, 'strict') && result.counts.warning > 0) || (polyglot?.errors ?? 0) > 0;
1042
+ const lead = violated ? 'boundary violations need attention' : 'boundary check needs attention';
1043
+ process.stdout.write(`\nVerdict: ${lead} — ${parts.join(', ') || 'see above'}\n`);
1044
+ const tail = verdictLine(env, '');
1045
+ if (tail)
1046
+ process.stdout.write(`${tail}\n`);
1047
+ return exit;
1048
+ }
1049
+ const line = verdictLine(env, boundaryCleanSentence(result, polyglot?.warnings ?? 0), exit === ExitCode.NotVerified ? boundaryNotVerifiedLead(result, env) : undefined);
1050
+ if (line)
1051
+ process.stdout.write(`\n${line}\n`);
1052
+ if (exit === ExitCode.NotVerified && env.coverage.expected === 0) {
1053
+ process.stdout.write(` Pass --${ALLOW_EMPTY_FLAG} to accept an empty selection explicitly.\n`);
1054
+ }
1055
+ return exit;
1056
+ }
1057
+ /**
1058
+ * `check boundaries --diff-against <candidate>` — what a proposed rule set would
1059
+ * ADD and REMOVE, before anyone commits a tightening. The same engine as the
1060
+ * gate, one scan for both sides (the orchestrator's `diffBoundaryRuleSets`).
1061
+ * Exit 1 when the proposal adds an error-severity violation, 2 when a
1062
+ * candidate rule's scope is dead (its delta proves nothing), 0 otherwise.
1063
+ */
1064
+ async function checkBoundaryRuleDiff(args, inspection, raw) {
1065
+ const cwd = resolveCwd(args);
1066
+ const wantJson = flagBool(args, 'json');
1067
+ const loaded = await loadBoundaryRuleFileArg(cwd, inspection.projectRoot, raw, 'diff-against');
1068
+ if (!loaded.ok) {
1069
+ process.stderr.write(`${loaded.message}\n`);
1070
+ return ExitCode.UsageError;
1071
+ }
1072
+ const onlyRuleId = flagString(args, 'rule');
1073
+ const diff = diffBoundaryRuleSets(inspection, { path: loaded.path, rules: loaded.rules, loadIssues: loaded.loadIssues }, {
1074
+ ...(onlyRuleId ? { onlyRuleId } : {}),
1075
+ includeComments: flagBool(args, 'include-comments'),
1076
+ failOnDeadUnits: flagBool(args, 'fail-on-dead-units'),
1077
+ });
1078
+ if (diff.unknownRuleId !== undefined) {
1079
+ process.stderr.write(`No boundary rule "${diff.unknownRuleId}" in the active or the candidate set.\n`);
1080
+ return ExitCode.UsageError;
1081
+ }
1082
+ const skippedCandidates = diff.candidateCoverage.filter((c) => c.status === 'skipped');
1083
+ const rows = [
1084
+ ...diff.candidateCoverage.map((c) => {
1085
+ const added = diff.added.filter((v) => v.ruleId === c.ruleId);
1086
+ const per = diff.perRule.find((p) => p.ruleId === c.ruleId);
1087
+ return {
1088
+ id: c.ruleId,
1089
+ type: 'boundary',
1090
+ // A candidate that checked nothing and fails on empty WOULD fail the
1091
+ // gate: `failed` (skipReason kept), the same mapping as the gate's own
1092
+ // rows and the wiring plane — and the orchestrator proposes 1 for it.
1093
+ status: c.status === 'skipped'
1094
+ ? c.failedOnEmpty
1095
+ ? 'failed'
1096
+ : 'skipped'
1097
+ : added.length > 0
1098
+ ? 'failed'
1099
+ : 'passed',
1100
+ severity: c.severity === 'error' ? 'error' : 'warning',
1101
+ counts: {
1102
+ filesInScope: c.filesInScope,
1103
+ added: per?.added ?? 0,
1104
+ removed: per?.removed ?? 0,
1105
+ unchanged: per?.unchanged ?? 0,
1106
+ },
1107
+ violations: added.map((v) => ({ id: v.importSpecifier, file: v.file, line: v.line, message: v.message })),
1108
+ ...(c.skipReason ? { skipReason: c.skipReason } : {}),
1109
+ coverage: c.coverage,
1110
+ // Round 13: the candidate's expectEmpty acceptance and unit lines, as
1111
+ // on the gate's own rows.
1112
+ ...(c.unitAcceptance ? { unitAcceptance: c.unitAcceptance } : {}),
1113
+ ...(c.units ? { units: c.units } : {}),
1114
+ };
1115
+ }),
1116
+ ...diff.loadIssues.map(boundaryLoadIssueRow),
1117
+ ];
1118
+ // The candidate's dead selector units and expectEmpty units — settled by THE
1119
+ // unread re-settle `check boundaries` uses (round 13, P2) — and the units the
1120
+ // one predicate fails under `--fail-on-dead-units` (the orchestrator folds
1121
+ // them into `proposedExit`): the same valve and the same "no ✓ over a dead
1122
+ // unit" clean line as the main path (round 11 review R11-GAP-10).
1123
+ const candidateDead = diff.candidateCoverage.flatMap((c) => c.deadUnits);
1124
+ const proposed = diff.proposedExit === ExitCode.Failure
1125
+ ? ExitCode.Failure
1126
+ : skippedCandidates.length > 0
1127
+ ? ExitCode.NotVerified
1128
+ : ExitCode.VerifiedPass;
1129
+ const candidates = diff.candidateCoverage.length;
1130
+ const runCoverage = {
1131
+ unit: 'candidate rules',
1132
+ expected: candidates,
1133
+ examined: candidates - skippedCandidates.length,
1134
+ ...(skippedCandidates.length > 0
1135
+ ? {
1136
+ unexamined: skippedCandidates.map((c) => c.ruleId),
1137
+ reason: 'their from globs reach no governed file — the delta for them proves nothing',
1138
+ }
1139
+ : {}),
1140
+ ...(candidates === 0 ? { reason: 'no candidate rule is in the selected scope' } : {}),
1141
+ ...allowEmptyValve(args, candidates),
1142
+ };
1143
+ const env = buildGateEnvelope('check boundaries', proposed, rows, runCoverage);
1144
+ if (wantJson) {
1145
+ process.stdout.write(asJson({ ...diff, exitCode: env.exit, accepted: env.accepted, gate: env }) + '\n');
1146
+ return env.exit;
1147
+ }
1148
+ const fmt = (c) => `${c.error} errors, ${c.warning} warnings, ${c.info} info`;
1149
+ process.stdout.write(header('Boundary rule diff (dry run — nothing written)'));
1150
+ process.stdout.write(kv('candidate', diff.candidateFile) + '\n');
1151
+ process.stdout.write(kv('rules added', diff.rulesAdded.join(', ') || '(none)') + '\n');
1152
+ process.stdout.write(kv('rules replaced', diff.rulesReplaced.join(', ') || '(none)') + '\n');
1153
+ process.stdout.write(kv('before', `${diff.before.rules} rule(s) — ${fmt(diff.before.counts)}`) + '\n');
1154
+ process.stdout.write(kv('after', `${diff.after.rules} rule(s) — ${fmt(diff.after.counts)}`) + '\n');
1155
+ process.stdout.write(kv('delta', `+${diff.added.length} added / -${diff.removed.length} removed / =${diff.unchanged} unchanged`) + '\n');
1156
+ process.stdout.write(kv('edges', `${diff.edgeLevel.newlyFlagged.length} newly flagged, ${diff.edgeLevel.noLongerFlagged.length} no longer flagged (any rule)`) + '\n');
1157
+ process.stdout.write('\n Per rule:\n');
1158
+ for (const p of diff.perRule) {
1159
+ process.stdout.write(` +${p.added} -${p.removed} =${p.unchanged} ${p.ruleId}\n`);
1160
+ }
1161
+ if (diff.added.length > 0) {
1162
+ process.stdout.write('\n Added:\n');
1163
+ for (const v of diff.added.slice(0, 20)) {
1164
+ process.stdout.write(` + ${v.severity.toUpperCase().padEnd(8)} ${v.ruleId} ${v.file}:${v.line} "${v.importSpecifier}"\n`);
1165
+ }
1166
+ if (diff.added.length > 20)
1167
+ process.stdout.write(` … (${diff.added.length - 20} more — --json has every one)\n`);
1168
+ }
1169
+ if (diff.removed.length > 0) {
1170
+ process.stdout.write('\n Removed:\n');
1171
+ for (const v of diff.removed.slice(0, 20)) {
1172
+ process.stdout.write(` - ${v.severity.toUpperCase().padEnd(8)} ${v.ruleId} ${v.file}:${v.line} "${v.importSpecifier}"\n`);
1173
+ }
1174
+ if (diff.removed.length > 20)
1175
+ process.stdout.write(` … (${diff.removed.length - 20} more — --json has every one)\n`);
1176
+ }
1177
+ for (const c of skippedCandidates) {
1178
+ process.stdout.write(` – ${c.ruleId} checked nothing — ${c.skipReason ?? 'no governed file'}\n`);
1179
+ }
1180
+ for (const c of diff.candidateCoverage) {
1181
+ for (const d of c.deadUnits)
1182
+ process.stdout.write(` • dead [${d.unit}] ${d.ruleId}: ${d.selector} — ${d.reason}\n`);
1183
+ }
1184
+ // Round 13: a candidate's expectEmpty marker whose target already exists.
1185
+ for (const u of diff.wentLive) {
1186
+ process.stdout.write(` • went live [${u.unit}] ${u.ruleId}: ${u.selector} — ${u.reason}\n`);
1187
+ }
1188
+ for (const issue of diff.loadIssues) {
1189
+ process.stdout.write(` ERROR ${issue.file}: ${boundaryLoadIssueLabel(issue)} — ${issue.issues.join('; ')} — NOT evaluated\n`);
1190
+ }
1191
+ if (env.exit === ExitCode.Failure) {
1192
+ const addedErrors = diff.added.filter((v) => v.severity === 'error').length;
1193
+ const onEmpty = diff.candidateCoverage.filter((c) => c.failedOnEmpty === true).length;
1194
+ const failingDead = diff.failingUnits.filter((u) => u.state === UnitLivenessState.Dead).length;
1195
+ const failingLive = diff.failingUnits.filter((u) => u.state === UnitLivenessState.WentLive).length;
1196
+ const parts = [];
1197
+ if (addedErrors > 0)
1198
+ parts.push(`ADDS ${addedErrors} error-severity violation(s)`);
1199
+ if (diff.loadIssues.length > 0)
1200
+ parts.push(`has ${diff.loadIssues.length} invalid rule(s)`);
1201
+ if (onEmpty > 0)
1202
+ parts.push(`has ${onEmpty} rule(s) matching nothing (failOnEmpty — it would fail the gate)`);
1203
+ if (failingDead > 0)
1204
+ parts.push(`has ${failingDead} dead selector unit(s) (--fail-on-dead-units)`);
1205
+ if (failingLive > 0)
1206
+ parts.push(`has ${failingLive} went-live expectEmpty unit(s) (--fail-on-dead-units)`);
1207
+ process.stdout.write(`\nVerdict: the candidate ${parts.join(' and ') || 'fails'}.\n`);
1208
+ const tail = verdictLine(env, '');
1209
+ if (tail)
1210
+ process.stdout.write(`${tail}\n`);
1211
+ return env.exit;
1212
+ }
1213
+ // The ✓ ONLY when nothing was reported above: a dead unit or a went-live
1214
+ // marker printed a line earlier names what the candidate enforces less than
1215
+ // it says — the main path's convention (`boundaryCleanSentence`), never a ✓
1216
+ // under it. An intended-empty unit prints its acceptance below the ✓.
1217
+ const clauses = [];
1218
+ if (candidateDead.length > 0) {
1219
+ clauses.push(`${candidateDead.length} dead selector unit(s) reported above (--fail-on-dead-units to fail on them)`);
1220
+ }
1221
+ if (diff.wentLive.length > 0) {
1222
+ clauses.push(`${diff.wentLive.length} expectEmpty unit(s) went live (remove each stale expectEmpty marker)`);
1223
+ }
1224
+ const clean = clauses.length > 0
1225
+ ? `Verdict: the candidate adds no error-severity violation — ${clauses.join('; ')}.`
1226
+ : 'Verdict: the candidate adds no error-severity violation. ✓';
1227
+ const line = verdictLine(env, clean, env.exit === ExitCode.NotVerified
1228
+ ? skippedCandidates.length > 0
1229
+ ? 'Verdict: NOT VERIFIED — a candidate rule checked nothing, so its delta proves nothing.'
1230
+ : "Verdict: NOT VERIFIED — part of a candidate rule's scope could not be read, so its delta proves nothing."
1231
+ : undefined);
1232
+ if (line)
1233
+ process.stdout.write(`\n${line}\n`);
1234
+ return env.exit;
522
1235
  }
523
1236
  // ────────────────────────────────────────────────────────────────────────
524
1237
  // ────────────────────────────────────────────────────────────────────────
525
1238
  // Subcommand: wiring — "declared but not wired" completeness checks
526
1239
  // ────────────────────────────────────────────────────────────────────────
527
- const WIRING_CHECK_USAGE = 'shrk check wiring [--changed-only] [--since <ref>] [--base <ref>] [--only <ids>] [--explain <ruleId>] [--json] [--strict]\n' +
1240
+ const WIRING_CHECK_USAGE = 'shrk check wiring [--changed-only] [--since <ref>] [--base <ref>] [--only <ids>] [--explain <ruleId>]\n' +
1241
+ ' [--fix [--write]] [--json] [--strict]\n' +
528
1242
  ' Cross-file "declared but not wired" completeness gate. Scope flags select rules by\n' +
529
1243
  ' FOOTPRINT — a rule fires when the diff touches EITHER its declared or its registered\n' +
530
1244
  ' side (so a registration edited in file B fires a rule declared in file A).\n' +
@@ -532,25 +1246,34 @@ const WIRING_CHECK_USAGE = 'shrk check wiring [--changed-only] [--since <ref>] [
532
1246
  ' --since <ref> scope the diff to changes since <ref> (--base is a synonym)\n' +
533
1247
  ' --only <ids> run only these rule ids (comma-separated)\n' +
534
1248
  ' --explain <id> dry-run ONE rule and print the declared/registered sets it extracts\n' +
1249
+ ' --fix plan the deterministic repair (append the token to its sink array);\n' +
1250
+ ' dry-run by default, prints every edit AND any import it must add.\n' +
1251
+ ' Refuses anything ambiguous — see `needs-import` in docs/wiring.md.\n' +
1252
+ ' --write apply the planned edits (only meaningful with --fix)\n' +
535
1253
  ' Exit: 0 verified pass · 1 violations · 2 not-verified (0 rules evaluated in scope).';
536
1254
  /**
537
- * The honest exit code for a wiring run.
1255
+ * The PROPOSED exit for a wiring run — the envelope then settles it against
1256
+ * every rule's coverage and the run's.
538
1257
  *
539
1258
  * The banner already said "Not a full green" when some rules were skipped, but
540
1259
  * the code an agent chains on returned `0` — a rule that enforced NOTHING was
541
1260
  * masked by its passing siblings. The verdict must match the sentence:
542
1261
  *
543
1262
  * `1` violations (or a `failOnEmpty` skip, which the engine folds into the verdict)
544
- * `2` nothing evaluated, OR anything skipped — partially verified is not verified
545
- * `0` only when every configured rule actually ran and passed
1263
+ * `2` rules were selected but none ran, OR anything skipped — partially
1264
+ * verified is not verified
1265
+ * `0` otherwise. That includes an EMPTY selection (no rule's footprint
1266
+ * intersects the changeset): its run coverage (expected 0) settles it to
1267
+ * 2 unless `--allow-empty` accepted it. Proposing 2 there made the valve
1268
+ * unreachable on exactly the case it exists for.
546
1269
  */
547
- function wiringExitCode(report, evaluated) {
1270
+ function wiringExitCode(report, evaluated, selected) {
548
1271
  if (report.verdict === 'errors')
549
1272
  return ExitCode.Failure;
550
- if (evaluated === 0)
551
- return ExitCode.NotVerified;
552
1273
  if (report.skipped.length > 0)
553
1274
  return ExitCode.NotVerified;
1275
+ if (evaluated === 0 && selected > 0)
1276
+ return ExitCode.NotVerified;
554
1277
  return ExitCode.VerifiedPass;
555
1278
  }
556
1279
  /**
@@ -562,13 +1285,20 @@ function wiringExitCode(report, evaluated) {
562
1285
  * with the reason, because a gate that silently half-fixes is worse than one
563
1286
  * that does nothing — the user must be able to see what was left.
564
1287
  */
565
- function runWiringFix(cwd, rules, report, write, wantJson) {
1288
+ function runWiringFix(cwd, rules, report, write, wantJson, settle, excludeDirs = []) {
566
1289
  const allEdits = [];
567
1290
  const allSkips = [];
568
- // Read every file a sink could live in, once.
1291
+ // Read every file a sink could live in, once — over THE plane scan scope
1292
+ // the check itself walked (`planeScanExcludeDirs`). Reading more (the
1293
+ // SharkCraft dir) made a sink the check never saw look like a second
1294
+ // candidate, so a fixable token was refused as `ambiguous-sink-file`.
569
1295
  const globs = [...new Set(rules.flatMap((r) => wiringGlobsOf(r)))];
570
- const cache = readMatchingFiles(cwd, globs, new Set());
571
- const files = [...cache.entries()].map(([path, content]) => ({ path, content }));
1296
+ // A sink file over the read cap is not in `files`, so the planner cannot
1297
+ // find its array and refuses that edit (listed below); it never guesses.
1298
+ const files = [...readMatchingFiles(cwd, globs, new Set(excludeDirs)).files.entries()].map(([path, content]) => ({
1299
+ path,
1300
+ content,
1301
+ }));
572
1302
  for (const rule of rules) {
573
1303
  const violations = report.violations.filter((v) => v.ruleId === rule.id);
574
1304
  if (violations.length === 0)
@@ -589,6 +1319,12 @@ function runWiringFix(cwd, rules, report, write, wantJson) {
589
1319
  written += 1;
590
1320
  }
591
1321
  }
1322
+ // ONE proposed exit for text and JSON (they used to differ: JSON only failed
1323
+ // when nothing at all was fixable). An unfixable violation still means the
1324
+ // gate is red. Then SETTLED against the check's own rule + run coverage: the
1325
+ // fix cannot examine what the rules never examined.
1326
+ const fixExit = allSkips.length > 0 ? ExitCode.Failure : ExitCode.VerifiedPass;
1327
+ const env = settle(fixExit);
592
1328
  if (wantJson) {
593
1329
  process.stdout.write(asJson({
594
1330
  schema: 'sharkcraft.wiring-fix/v1',
@@ -596,17 +1332,29 @@ function runWiringFix(cwd, rules, report, write, wantJson) {
596
1332
  filesWritten: written,
597
1333
  edits: allEdits.map(({ nextContent: _drop, ...rest }) => rest),
598
1334
  skipped: allSkips,
1335
+ exitCode: env.exit,
1336
+ gate: env,
599
1337
  }) + '\n');
600
- return allEdits.length === 0 && allSkips.length > 0 ? ExitCode.Failure : ExitCode.VerifiedPass;
1338
+ return env.exit;
601
1339
  }
602
1340
  process.stdout.write(header(write ? 'Wiring fix (applied)' : 'Wiring fix (dry run)'));
603
1341
  if (allEdits.length === 0 && allSkips.length === 0) {
604
1342
  process.stdout.write(' Nothing to fix — no declared-but-unregistered tokens.\n');
605
- return ExitCode.VerifiedPass;
1343
+ const line = verdictLine(env, '', '--fix repairs declared-but-unregistered tokens only; it cannot examine what the rules never examined.');
1344
+ if (line)
1345
+ process.stdout.write(`\n${line}\n`);
1346
+ return env.exit;
606
1347
  }
607
1348
  for (const e of allEdits) {
608
1349
  process.stdout.write(` ${write ? 'wrote ' : 'would add'} ${e.token} → ${e.file}:${e.line}\n`);
609
- process.stdout.write(` + ${e.insert}\n`);
1350
+ // Show BOTH halves of the edit. An array append whose import is invisible
1351
+ // in the preview is exactly how the reviewer approves a change they have
1352
+ // not actually seen — and the import is the half that decides whether the
1353
+ // result compiles.
1354
+ if (e.importInsert !== undefined) {
1355
+ process.stdout.write(` + ${e.importInsert}${e.importLine !== undefined ? ` (line ${e.importLine})` : ''}\n`);
1356
+ }
1357
+ process.stdout.write(` + ${e.insert}${e.importInsert !== undefined ? ` (line ${e.line})` : ''}\n`);
610
1358
  }
611
1359
  if (allSkips.length > 0) {
612
1360
  process.stdout.write(`\n Left untouched (${allSkips.length}) — not mechanically unambiguous:\n`);
@@ -617,18 +1365,16 @@ function runWiringFix(cwd, rules, report, write, wantJson) {
617
1365
  process.stdout.write(write
618
1366
  ? `\n${written} file(s) written. Re-run \`shrk check wiring\` to confirm, and review the diff.\n`
619
1367
  : '\nDry run — nothing written. Re-run with `--write` to apply.\n');
620
- // An unfixable violation still means the gate is red.
621
- return allSkips.length > 0 ? ExitCode.Failure : ExitCode.VerifiedPass;
1368
+ // The final line and the exit come from the SETTLED verdict: an unfixable
1369
+ // violation is 1, and a fix over a partially-examined scope is never 0.
1370
+ const tail = verdictLine(env, '');
1371
+ if (tail)
1372
+ process.stdout.write(`\n${tail}\n`);
1373
+ return env.exit;
622
1374
  }
623
1375
  async function checkWiring(args) {
624
1376
  const cwd = resolveCwd(args);
625
- // `--help`/`-h` on the subverb documents its own scoping flags instead of
626
- // silently running the bare check (a25 §3.2). The check dispatcher doesn't
627
- // intercept a subverb-level `--help`, so handle it here.
628
- if (flagBool(args, 'help') || flagBool(args, 'h')) {
629
- process.stdout.write(WIRING_CHECK_USAGE + '\n');
630
- return 0;
631
- }
1377
+ // `--help` / `-h` never reaches here: the dispatcher answers it (dispatch/help-intercept.ts).
632
1378
  const wantJson = flagBool(args, 'json');
633
1379
  const changedOnly = flagBool(args, 'changed-only');
634
1380
  // `--base <ref>` is an accepted synonym for `--since <ref>` (a25 §3.2) —
@@ -640,17 +1386,31 @@ async function checkWiring(args) {
640
1386
  // misleading "no rules configured" + exit 0.
641
1387
  const loaded = await resolveProjectConfig(cwd);
642
1388
  if (!loaded.ok) {
1389
+ // The gate never STARTED — its rules live in the config it could not load —
1390
+ // so this is a usage error (3), never `1`: a CI step reads 1 as "violations
1391
+ // found". `policy-lint` and `gates check` answer the same config the same way.
643
1392
  const msg = loaded.error.message;
644
1393
  if (wantJson) {
645
- process.stdout.write(asJson({ schema: 'sharkcraft.wiring/v1', error: msg, rules: [], violations: [], diagnostics: [msg], verdict: 'errors' }) + '\n');
646
- return 1;
1394
+ process.stdout.write(asJson({
1395
+ schema: 'sharkcraft.wiring/v1',
1396
+ error: msg,
1397
+ rules: [],
1398
+ violations: [],
1399
+ diagnostics: [msg],
1400
+ verdict: 'usage-error',
1401
+ exitCode: ExitCode.UsageError,
1402
+ }) + '\n');
1403
+ return ExitCode.UsageError;
647
1404
  }
648
1405
  process.stdout.write(header('Wiring check'));
649
1406
  process.stdout.write(` ✗ Could not load config: ${msg}\n Run \`shrk doctor\` for details.\n`);
650
- return 1;
1407
+ return ExitCode.UsageError;
651
1408
  }
652
1409
  const rules = loaded.value.config.wiringRules ?? [];
653
1410
  const planeDiagnostics = loaded.value.planeDiagnostics;
1411
+ // A pack wiring rule the merge seam rejected is a configured rule that did
1412
+ // NOT run — an errored row, exit 1 (round 12 review, R12-X1).
1413
+ const rejectedAll = seamRejectedRules(loaded.value, ['wiring']);
654
1414
  // `--explain <ruleId>`: dry-run ONE rule and print the declared + registered
655
1415
  // sets it extracts (file:line), the set-difference, and the verdict — the
656
1416
  // author-loop view of what the gate sees, without re-running the whole gate.
@@ -661,6 +1421,19 @@ async function checkWiring(args) {
661
1421
  return ExitCode.UsageError;
662
1422
  }
663
1423
  const rule = rules.find((r) => r.id === explainId);
1424
+ const rejectedRule = rule ? undefined : rejectedAll.find((r) => r.id === explainId);
1425
+ if (rejectedRule) {
1426
+ // Declared by a pack, refused by the merge seam: there is nothing to
1427
+ // dry-run, and the rule never runs — a failure, never a not-found.
1428
+ const message = rejectedRule.error ?? 'failed validation';
1429
+ if (wantJson) {
1430
+ process.stdout.write(asJson({ ok: false, error: 'rejected', ruleId: explainId, message, exitCode: ExitCode.Failure }) + '\n');
1431
+ }
1432
+ else {
1433
+ process.stdout.write(`Wiring rule "${explainId}" was REJECTED — ${message}\n`);
1434
+ }
1435
+ return ExitCode.Failure;
1436
+ }
664
1437
  if (!rule) {
665
1438
  const ids = rules.map((r) => r.id);
666
1439
  if (wantJson) {
@@ -670,17 +1443,42 @@ async function checkWiring(args) {
670
1443
  process.stderr.write(`No wiring rule "${explainId}". Configured rules: ${ids.length > 0 ? ids.join(', ') : '(none)'}\n`);
671
1444
  return ExitCode.UsageError;
672
1445
  }
673
- return renderWiringExplain(explainWiring(cwd, rule), wantJson);
1446
+ // `check wiring` is a verdict verb, so its `--explain` returns the explained
1447
+ // rule's SETTLED exit (0 / 1 / 2) — never a 0 over a rule the gate reports
1448
+ // partial. `wiring explain` / `gates explain` stay informational (0).
1449
+ const explained = explainWiring(cwd, rule, { excludeDirs: planeScanExcludeDirs(cwd, loaded.value.sharkcraftDir) });
1450
+ renderWiringExplain(explained, wantJson);
1451
+ return settleWiringExplain(explained).exit;
674
1452
  }
675
- if (rules.length === 0) {
1453
+ if (rules.length === 0 && rejectedAll.length === 0) {
1454
+ // Nothing declared is NOT a pass — the request covered zero rules, so it
1455
+ // proved nothing: `2`, unless the caller accepts the empty set explicitly.
1456
+ const env = buildGateEnvelope('check wiring', ExitCode.VerifiedPass, [], {
1457
+ unit: 'wiring rules',
1458
+ expected: 0,
1459
+ examined: 0,
1460
+ reason: 'no wiringRules[] declared',
1461
+ ...allowEmptyValve(args, 0),
1462
+ });
676
1463
  if (wantJson) {
677
- process.stdout.write(asJson({ schema: 'sharkcraft.wiring/v1', rules: [], violations: [], verdict: 'pass' }) + '\n');
678
- return 0;
1464
+ process.stdout.write(asJson({
1465
+ schema: 'sharkcraft.wiring/v1',
1466
+ rules: [],
1467
+ violations: [],
1468
+ verdict: env.exit === ExitCode.VerifiedPass ? 'pass' : 'not-verified',
1469
+ exitCode: env.exit,
1470
+ gate: env,
1471
+ }) + '\n');
1472
+ return env.exit;
679
1473
  }
680
1474
  process.stdout.write(header('Wiring check'));
681
1475
  process.stdout.write(' No wiring rules configured. Declare `wiringRules[]` in sharkcraft.config.ts to enable\n' +
682
1476
  ' cross-file "declared but not wired" checks (see docs/wiring.md).\n');
683
- return 0;
1477
+ process.stdout.write(`\n${verdictLine(env, 'Nothing declared — accepted.')}\n`);
1478
+ if (env.exit !== ExitCode.VerifiedPass) {
1479
+ process.stdout.write(` Pass --${ALLOW_EMPTY_FLAG} to accept an empty rule set explicitly.\n`);
1480
+ }
1481
+ return env.exit;
684
1482
  }
685
1483
  let changedFiles;
686
1484
  if (changedOnly || since) {
@@ -692,6 +1490,8 @@ async function checkWiring(args) {
692
1490
  changedFiles = changed.files;
693
1491
  }
694
1492
  const reportRaw = runWiring(cwd, rules, {
1493
+ // THE plane scan scope, so this verb and `gates check` walk the same tree.
1494
+ excludeDirs: planeScanExcludeDirs(cwd, loaded.value.sharkcraftDir),
695
1495
  ...(changedOnly || since ? { changedOnly: true, changedFiles: changedFiles ?? [] } : {}),
696
1496
  ...(only ? { only: only.split(',').map((s) => s.trim()).filter(Boolean) } : {}),
697
1497
  });
@@ -706,11 +1506,21 @@ async function checkWiring(args) {
706
1506
  // evaluated — rules that actually ran a comparison (globs matched >0 files).
707
1507
  // skippedByScope — configured − selected (dropped by the diff/only narrowing).
708
1508
  // matchedNothing — selected − evaluated (in scope but matched 0 files).
709
- const configured = rules.length;
710
- const selected = report.rules.length;
1509
+ // rejected — pack rules the merge seam refused: declared, never run.
1510
+ const onlyIds = only ? only.split(',').map((s) => s.trim()).filter(Boolean) : undefined;
1511
+ const rejected = onlyIds ? rejectedAll.filter((r) => onlyIds.includes(r.id)) : rejectedAll;
1512
+ const configured = rules.length + rejectedAll.length;
1513
+ const selected = report.rules.length + rejected.length;
711
1514
  const evaluated = report.evaluated;
1515
+ // Round 13 (K6): `report.evaluated` counts a rule accepted as intended-empty
1516
+ // (so the `evaluated === 0` guard never reads an accepted plan as "nothing
1517
+ // ran"), but that rule examined 0 files — every PRINTED "evaluated" count
1518
+ // leaves it out and names it apart: `N evaluated, M accepted as intended-empty`.
1519
+ const acceptedEmpty = report.acceptedEmpty;
1520
+ const evaluatedShown = evaluated - acceptedEmpty;
1521
+ const acceptedNote = acceptedEmptyNote(acceptedEmpty);
712
1522
  const skippedByScope = Math.max(0, configured - selected);
713
- const matchedNothing = Math.max(0, selected - evaluated);
1523
+ const matchedNothing = Math.max(0, report.rules.length - evaluated);
714
1524
  const notVerified = Math.max(0, configured - evaluated);
715
1525
  const allEvaluated = evaluated === configured;
716
1526
  const scopeNote = changedOnly || since ? ' by --changed-only' : '';
@@ -719,21 +1529,77 @@ async function checkWiring(args) {
719
1529
  parts.push(`${skippedByScope} skipped${scopeNote}`);
720
1530
  if (matchedNothing > 0)
721
1531
  parts.push(`${matchedNothing} matched no files`);
1532
+ if (rejected.length > 0)
1533
+ parts.push(`${rejected.length} rejected at the pack-plane merge seam`);
722
1534
  const breakdown = parts.length > 0 ? ` (${parts.join(', ')})` : '';
723
1535
  // Rule ids selected by footprint (declared OR registered side intersecting the
724
1536
  // diff) — positive evidence the gate ran the RIGHT rules for the change's
725
1537
  // blast radius, not just the rules whose literal files were edited (a25 §3.2).
726
1538
  const selectedRuleIds = report.rules.map((r) => r.ruleId);
727
1539
  const scoped = changedOnly || since !== undefined;
1540
+ // Settle first, render second. The envelope is built for text AND JSON (and
1541
+ // --fix), so every path returns the SAME settled exit, and a subset rule that
1542
+ // passed over registered tokens its declared selector never produced reads
1543
+ // `partial` — exit 2, naming them — instead of a green 0.
1544
+ const proposed = rejected.length > 0 ? ExitCode.Failure : wiringExitCode(report, evaluated, report.rules.length);
1545
+ const unexaminedRules = [
1546
+ ...report.skipped.map((s) => s.ruleId),
1547
+ ...report.rules.filter((r) => r.status === 'error').map((r) => r.ruleId),
1548
+ ...rejected.map((r) => r.id),
1549
+ ];
1550
+ const ruleResults = [...report.rules.map((r) => {
1551
+ const skip = report.skipped.find((s) => s.ruleId === r.ruleId);
1552
+ return {
1553
+ id: r.ruleId,
1554
+ type: 'wiring',
1555
+ status: r.status,
1556
+ severity: r.severity,
1557
+ counts: { declared: r.declaredCount, registered: r.registeredCount },
1558
+ violations: r.violations.map((v) => ({
1559
+ id: v.token,
1560
+ file: v.file,
1561
+ line: v.line,
1562
+ ...(v.message ? { message: v.message } : {}),
1563
+ ...(v.hint ? { hint: v.hint } : {}),
1564
+ })),
1565
+ ...(skip ? { skipReason: skip.reason } : {}),
1566
+ ...(r.error ? { error: r.error } : {}),
1567
+ coverage: r.coverage,
1568
+ // The rule's `expectEmpty` acceptance and unit lines (round 13, lane G),
1569
+ // folded into the envelope's one settle — the accepted line comes from it.
1570
+ ...(r.unitAcceptance !== undefined ? { unitAcceptance: r.unitAcceptance } : {}),
1571
+ ...(r.units !== undefined ? { units: r.units } : {}),
1572
+ };
1573
+ }), ...rejected];
1574
+ const runCoverage = {
1575
+ unit: 'wiring rules',
1576
+ expected: selected,
1577
+ examined: selected - unexaminedRules.length,
1578
+ ...(unexaminedRules.length > 0
1579
+ ? { unexamined: unexaminedRules, reason: 'checked nothing or could not run' }
1580
+ : {}),
1581
+ ...(selected === 0
1582
+ ? { reason: scoped ? 'no rule footprint intersects the changeset' : 'no rule selected' }
1583
+ : {}),
1584
+ ...allowEmptyValve(args, selected),
1585
+ };
1586
+ const env = buildGateEnvelope('check wiring', proposed, ruleResults, runCoverage);
1587
+ const exit = env.exit;
728
1588
  // ── --fix: deterministic, heavily-guarded autofix ─────────────────────
1589
+ // The fix repairs declared-but-unregistered tokens; it cannot examine what
1590
+ // the rules never examined. So its exit is settled against the SAME rule and
1591
+ // run coverage: "Nothing to fix" over a partial rule is 2, never 0.
729
1592
  if (flagBool(args, 'fix')) {
730
- return runWiringFix(cwd, rules, report, flagBool(args, 'write'), wantJson);
1593
+ return runWiringFix(cwd, rules, report, flagBool(args, 'write'), wantJson, (fixExit) => buildGateEnvelope('check wiring', fixExit, ruleResults, runCoverage), planeScanExcludeDirs(cwd, loaded.value.sharkcraftDir));
731
1594
  }
732
1595
  if (wantJson) {
733
1596
  // Carry the honest counts so a machine consumer can tell "0 evaluated" from
734
1597
  // a real green, and see how many rules the scope skipped + which fired.
735
1598
  process.stdout.write(asJson({
736
1599
  ...report,
1600
+ // The plane verdict derives from the SETTLED exit — never `pass` next
1601
+ // to exitCode 2.
1602
+ verdict: planeVerdictForExit(exit, report.verdict),
737
1603
  configured,
738
1604
  selected,
739
1605
  evaluated,
@@ -741,44 +1607,49 @@ async function checkWiring(args) {
741
1607
  matchedNothing,
742
1608
  notVerified,
743
1609
  selectedRuleIds,
1610
+ // Pack wiring rules the merge seam refused — errored rows in `gate.rules`.
1611
+ rejected: rejected.map((r) => ({ id: r.id, error: r.error ?? null })),
744
1612
  // Distinguish verified-pass / failure / not-verified for a chained gate.
745
- exitCode: wiringExitCode(report, evaluated),
1613
+ exitCode: exit,
746
1614
  // One shape across every plane — see docs/gate-json.md.
747
- gate: buildGateEnvelope('check wiring', wiringExitCode(report, evaluated), report.rules.map((r) => {
748
- const skip = report.skipped.find((s) => s.ruleId === r.ruleId);
749
- return {
750
- id: r.ruleId,
751
- type: 'wiring',
752
- status: r.status,
753
- severity: r.severity,
754
- counts: { declared: r.declaredCount, registered: r.registeredCount },
755
- violations: r.violations.map((v) => ({
756
- id: v.token,
757
- file: v.file,
758
- line: v.line,
759
- ...(v.message ? { message: v.message } : {}),
760
- ...(v.hint ? { hint: v.hint } : {}),
761
- })),
762
- ...(skip ? { skipReason: skip.reason } : {}),
763
- ...(r.error ? { error: r.error } : {}),
764
- };
765
- })),
1615
+ gate: env,
766
1616
  }) + '\n');
767
- // `evaluated === 0` = nothing checked in scope → NOT verified (`2`), never a
768
- // silent `0` an agent's `&& next` would march past (a25 §1.1).
769
- return wiringExitCode(report, evaluated);
1617
+ return exit;
770
1618
  }
771
1619
  process.stdout.write(header('Wiring check'));
1620
+ if (rejected.length > 0) {
1621
+ process.stdout.write(` ✗ ${rejected.length} pack wiring rule(s) failed validation at the pack-plane merge seam — NOT evaluated, FAILED:\n`);
1622
+ for (const r of rejected)
1623
+ process.stdout.write(` ✗ ${r.id} — ${r.error ?? 'failed validation'}\n`);
1624
+ process.stdout.write(' `shrk packs contributions` names every rejected entry.\n');
1625
+ }
772
1626
  // `evaluated` counts rules that actually ran a comparison (globs matched >0
773
- // files). When 0 rules evaluated but rules ARE configured, say so loudly —
774
- // "checked nothing" must never read as the green "every token is wired" pass,
775
- // and the exit code must say NOT verified (`2`) too, not a lying `0`.
1627
+ // files). When 0 rules evaluated, say so loudly — "checked nothing" must
1628
+ // never read as the green "every token is wired" pass, and the exit code
1629
+ // says NOT verified (`2`) too, not a lying `0` — unless the caller accepted
1630
+ // an empty scope with --allow-empty, which is printed as such.
776
1631
  if (evaluated === 0) {
777
- process.stdout.write(` ! 0 rules evaluated — NOT verified. ${configured} rule(s) configured${breakdown}; ` +
778
- 'none ran a comparison in scope. Wiring was not checked this is not a pass.\n');
779
- return ExitCode.NotVerified;
1632
+ process.stdout.write(exit === ExitCode.VerifiedPass
1633
+ ? ` 0 rules in scope — ${configured} rule(s) configured${breakdown}.\n`
1634
+ : ` ! 0 rules evaluated — NOT verified. ${configured} rule(s) configured${breakdown}; ` +
1635
+ 'none ran a comparison in scope. Wiring was not checked — this is not a pass.\n');
1636
+ for (const sk of report.skipped) {
1637
+ process.stdout.write(` ${sk.failed ? '✗' : '–'} ${sk.ruleId} ${sk.failed ? 'FAILED' : 'SKIPPED'} — ${sk.reason}\n`);
1638
+ }
1639
+ // THE empty-rule advice (round 13) — the 0-evaluated branch returned
1640
+ // before the only advice site, so a rule that matched nothing got none.
1641
+ for (const a of emptyRuleAdviceLines(report.skipped.map((sk) => ({ fails: sk.failed === true })))) {
1642
+ process.stdout.write(` ${a}.\n`);
1643
+ }
1644
+ const line = verdictLine(env, 'Nothing in scope — accepted.');
1645
+ if (line)
1646
+ process.stdout.write(`\n${line}\n`);
1647
+ if (selected === 0 && exit === ExitCode.NotVerified) {
1648
+ process.stdout.write(` Pass --${ALLOW_EMPTY_FLAG} to accept an empty changeset explicitly.\n`);
1649
+ }
1650
+ return exit;
780
1651
  }
781
- process.stdout.write(kv('rules evaluated', `${evaluated} of ${configured}${breakdown}`) + '\n');
1652
+ process.stdout.write(kv('rules evaluated', `${evaluatedShown} of ${configured}${acceptedNote}${breakdown}`) + '\n');
782
1653
  if (scoped && selectedRuleIds.length > 0) {
783
1654
  // Show WHICH rules the diff's footprint selected so the scoping is provable,
784
1655
  // not indistinguishable from plain changed-file scoping.
@@ -787,6 +1658,27 @@ async function checkWiring(args) {
787
1658
  const errors = report.violations.filter((v) => v.severity === 'error').length;
788
1659
  const warnings = report.violations.filter((v) => v.severity === 'warning').length;
789
1660
  process.stdout.write(kv('violations', `${errors} error(s), ${warnings} warning(s)`) + '\n');
1661
+ // Every rule that ran clean, WITH both counts, so a pass can be compared to
1662
+ // what it covered. A subset rule whose declared selector never produced some
1663
+ // registered tokens is `partial`, and says which ones it never examined.
1664
+ // THE shared unit-state block (round 13, K2): a dead glob of a rule that
1665
+ // still matched, and a LOCAL expectEmpty marker whose target appeared, are
1666
+ // listed below and withhold the ✓ (exit unchanged); a pack marker is INFO.
1667
+ const unitNotes = unitStateNotes(report.rules.map((r) => ({
1668
+ id: r.ruleId,
1669
+ ...(r.unitLiveness !== undefined ? { unitLiveness: r.unitLiveness } : {}),
1670
+ reportedEmpty: report.skipped.some((s) => s.ruleId === r.ruleId),
1671
+ })));
1672
+ const cleanRules = env.rules.filter((r) => r.status === 'passed' || r.status === 'partial');
1673
+ if (cleanRules.length > 0) {
1674
+ process.stdout.write('\n');
1675
+ for (const r of cleanRules) {
1676
+ const counts = `declared ${r.counts['declared'] ?? 0} / registered ${r.counts['registered'] ?? 0}`;
1677
+ process.stdout.write(r.status === 'passed'
1678
+ ? ` ${unitNotes.staleIds.has(r.id) ? '⚠' : '✓'} ${r.id} (${counts})\n`
1679
+ : ` ~ ${r.id} (${counts}) — PARTIAL: ${r.shortfall ?? 'not fully examined'}\n`);
1680
+ }
1681
+ }
790
1682
  // Misconfigured rules (uncompilable pattern / no capture group) — surface
791
1683
  // them loudly; a broken rule must never read as a silent green.
792
1684
  if (report.diagnostics.length > 0) {
@@ -802,10 +1694,12 @@ async function checkWiring(args) {
802
1694
  for (const sk of report.skipped) {
803
1695
  process.stdout.write(` ${sk.failed ? '✗' : '–'} ${sk.ruleId} ${sk.failed ? 'FAILED' : 'SKIPPED'} — ${sk.reason}\n`);
804
1696
  }
805
- if (report.skipped.some((sk) => !sk.failed)) {
806
- process.stdout.write(' Fix the selector, or set `failOnEmpty: true` once the rule is known to have\n' +
807
- ' real subjects. Run `shrk gates coverage` to audit every plane at once.\n');
1697
+ // THE empty-rule advice (round 13) one sentence per REAL `fails` value,
1698
+ // from the shared renderer: a failing (failOnEmpty) rule was told nothing.
1699
+ for (const a of emptyRuleAdviceLines(report.skipped.map((sk) => ({ fails: sk.failed === true })))) {
1700
+ process.stdout.write(` ${a}.\n`);
808
1701
  }
1702
+ process.stdout.write(' Run `shrk gates coverage` to audit every plane at once.\n');
809
1703
  }
810
1704
  // A sink that extracted 0 ids while the source had some is a real failure, but
811
1705
  // almost always a stale SINK glob — say so instead of listing N "unwired"
@@ -815,25 +1709,30 @@ async function checkWiring(args) {
815
1709
  continue;
816
1710
  process.stdout.write(`\n ! ${r.ruleId}: the registered side extracted 0 ids while ${r.declaredCount} were declared —\n` +
817
1711
  ` every declared token "fails". Check the registered glob before chasing the tokens.\n`);
1712
+ // When the engine knows WHY the sink came back empty, say it here — the
1713
+ // generic advice above would otherwise send the reader to inspect a glob
1714
+ // that is perfectly fine.
1715
+ if (r.sinkHint)
1716
+ process.stdout.write(` → ${r.sinkHint}\n`);
818
1717
  }
819
- if (report.violations.length === 0 && report.diagnostics.length === 0) {
820
- // A failOnEmpty skip produces no violation object but IS a failure.
821
- if (report.verdict === 'errors')
822
- return ExitCode.Failure;
823
- if (allEvaluated) {
824
- // Every configured rule ran the earned full green.
825
- process.stdout.write('\nNo wiring violations every declared token is registered. ✓\n');
826
- }
827
- else {
828
- // A subset ran: no violations AMONG WHAT RAN, but not a full green. Never
829
- // print the unqualified "every declared token is wired" success sentence.
830
- process.stdout.write(`\nNo wiring violations among the ${evaluated} rule(s) evaluated ` +
831
- `${notVerified} of ${configured} NOT verified${breakdown}. Not a full green.\n`);
832
- }
833
- return wiringExitCode(report, evaluated);
834
- }
835
- if (report.violations.length === 0) {
836
- return wiringExitCode(report, evaluated);
1718
+ // The final line comes from the SETTLED verdict only — never a ✓ sentence
1719
+ // printed directly, so the banner cannot disagree with `$?`.
1720
+ process.stdout.write(unitNotes.text);
1721
+ const notFull = `No wiring violations among the ${evaluatedShown} rule(s) evaluated${acceptedNote} — ` +
1722
+ `${notVerified} of ${configured} NOT verified${breakdown}. Not a full green.`;
1723
+ // A failOnEmpty skip produces no violation object but IS a failure, so it
1724
+ // falls through to the failure tail below.
1725
+ if (report.violations.length === 0 &&
1726
+ report.diagnostics.length === 0 &&
1727
+ report.verdict !== 'errors') {
1728
+ // Every configured rule ran — the earned full green. A subset ran no
1729
+ // violations AMONG WHAT RAN, never the unqualified success sentence.
1730
+ const line = verdictLine(env, allEvaluated
1731
+ ? qualifyCleanForUnits('No wiring violations — every declared token is registered. ✓', unitNotes)
1732
+ : notFull, proposed === ExitCode.NotVerified ? notFull : undefined);
1733
+ if (line)
1734
+ process.stdout.write(`\n${line}\n`);
1735
+ return exit;
837
1736
  }
838
1737
  // Group by rule for a readable report.
839
1738
  for (const r of report.rules) {
@@ -853,7 +1752,10 @@ async function checkWiring(args) {
853
1752
  if (hint)
854
1753
  process.stdout.write(` → ${hint}\n`);
855
1754
  }
856
- return report.verdict === 'errors' ? 1 : 0;
1755
+ const tail = verdictLine(env, '');
1756
+ if (tail)
1757
+ process.stdout.write(`\n${tail}\n`);
1758
+ return exit;
857
1759
  }
858
1760
  /**
859
1761
  * `shrk check orphans [--since <ref>] [--staged]`: first-class, diff-robust
@@ -892,9 +1794,19 @@ async function checkOrphans(args) {
892
1794
  return 2;
893
1795
  }
894
1796
  const scopeLabel = staged ? 'staged' : `vs ${scan.ref}`;
895
- // Nothing deleted there is nothing to check. Report a LOUD skip, not a
896
- // green "no orphans" "checked nothing" must never read as "verified clean".
1797
+ // What the scan examined against what the delete asked it to ONE
1798
+ // authority (deletedOrphanCoverage): every deleted source file the graph
1799
+ // indexes is expected; one the index does not know is unexamined.
1800
+ const coverage = deletedOrphanCoverage(scan);
1801
+ // Nothing deleted → there is nothing to check, and "checked nothing" must
1802
+ // never read as "verified clean": a LOUD skip that exits 2 (not verified), in
1803
+ // text AND JSON. A hook that runs this on every commit accepts the empty diff
1804
+ // EXPLICITLY with --allow-empty, which is printed as an acceptance.
897
1805
  if (scan.deleted.length === 0) {
1806
+ const emptyEnv = buildGateEnvelope('check orphans', ExitCode.VerifiedPass, [], {
1807
+ ...coverage,
1808
+ ...allowEmptyValve(args, coverage.expected),
1809
+ });
898
1810
  if (wantJson) {
899
1811
  process.stdout.write(asJson({
900
1812
  schema: 'sharkcraft.deleted-orphans/v1',
@@ -904,25 +1816,56 @@ async function checkOrphans(args) {
904
1816
  unresolvedDeleted: [],
905
1817
  orphans: [],
906
1818
  diagnostics: [`no deleted files (${scopeLabel}) — nothing to check`],
1819
+ exitCode: emptyEnv.exit,
1820
+ gate: emptyEnv,
907
1821
  }) + '\n');
908
- return 0;
1822
+ return emptyEnv.exit;
909
1823
  }
910
1824
  process.stdout.write(header('Orphan check'));
911
1825
  process.stdout.write(` ! Nothing deleted (${scopeLabel}) — orphan check skipped.\n`);
912
- return 0;
1826
+ process.stdout.write(`\n${verdictLine(emptyEnv, 'Nothing deleted — accepted.')}\n`);
1827
+ if (emptyEnv.exit !== ExitCode.VerifiedPass) {
1828
+ process.stdout.write(` Pass --${ALLOW_EMPTY_FLAG} to accept an empty diff explicitly (e.g. in a per-commit hook).\n`);
1829
+ }
1830
+ return emptyEnv.exit;
913
1831
  }
914
1832
  const report = scan.report;
1833
+ // The index can only answer for the deleted SOURCE files it knows. One it
1834
+ // does not know (the index predates it, or was rebuilt after the delete)
1835
+ // had its importers never checked: it is expected but unexamined, so a clean
1836
+ // result settles to 2 and names it. A deleted README is outside the scope.
1837
+ const env = buildGateEnvelope('check orphans', report.orphans.length > 0 ? ExitCode.Failure : ExitCode.VerifiedPass, [], { ...coverage, ...allowEmptyValve(args, coverage.expected) });
1838
+ const exit = env.exit;
915
1839
  if (wantJson) {
916
- process.stdout.write(asJson(report) + '\n');
917
- return report.orphans.length > 0 ? 1 : 0;
1840
+ process.stdout.write(asJson({
1841
+ ...report,
1842
+ ...(scan.indexDivergence ? { indexDivergence: scan.indexDivergence } : {}),
1843
+ exitCode: exit,
1844
+ gate: env,
1845
+ }) + '\n');
1846
+ return exit;
918
1847
  }
919
1848
  process.stdout.write(header('Orphan check'));
920
1849
  process.stdout.write(kv('deleted files', `${scan.deleted.length} (${scopeLabel})`) + '\n');
1850
+ process.stdout.write(kv('coverage', formatCoverage(coverage)) + '\n');
1851
+ // The importer side (a file added or edited since the index was built was
1852
+ // never read for imports) and the lead explaining a partial scope — worded
1853
+ // once and shared with `impact --deleted`, which answers the same question
1854
+ // from the same scan.
1855
+ const notes = deletedOrphanScopeNotes(scan, coverage, 'shrk check orphans');
1856
+ if (notes.index !== undefined)
1857
+ process.stdout.write(kv('index', notes.index) + '\n');
1858
+ const unindexedLead = notes.lead;
921
1859
  if (report.orphans.length === 0) {
922
- process.stdout.write('\nNo orphaned importers — nothing still references the deleted code. ✓\n');
923
1860
  for (const d of report.diagnostics.slice(0, 5))
924
1861
  process.stdout.write(` ! ${d}\n`);
925
- return 0;
1862
+ const line = verdictLine(env, 'No orphaned importers — nothing still references the deleted code. ✓', unindexedLead);
1863
+ if (line)
1864
+ process.stdout.write(`\n${line}\n`);
1865
+ if (exit === ExitCode.NotVerified && coverage.expected === 0) {
1866
+ process.stdout.write(` Pass --${ALLOW_EMPTY_FLAG} to accept a delete with no indexed source files explicitly.\n`);
1867
+ }
1868
+ return exit;
926
1869
  }
927
1870
  process.stdout.write(`\n${report.orphans.length} surviving importer(s) still reference deleted code:\n`);
928
1871
  for (const o of report.orphans.slice(0, 100)) {
@@ -935,14 +1878,55 @@ async function checkOrphans(args) {
935
1878
  }
936
1879
  for (const d of report.diagnostics.slice(0, 5))
937
1880
  process.stdout.write(` ! ${d}\n`);
938
- return 1;
1881
+ const tail = verdictLine(env, '');
1882
+ if (tail)
1883
+ process.stdout.write(`${tail}\n`);
1884
+ return exit;
939
1885
  }
940
1886
  // Main shrk check + subcommands
941
1887
  // ────────────────────────────────────────────────────────────────────────
942
1888
  export const checkCommand = {
943
1889
  name: 'check',
1890
+ // Declared for the dispatcher guard, `help` and the command index (round 11
1891
+ // §5.2): any other bare token is an unknown subcommand (`check rules` →
1892
+ // "`rules` is a command of its own"), never a silent full sweep at exit 0.
1893
+ positionals: PositionalMode.None,
1894
+ subverbs: [
1895
+ { name: 'boundaries', description: 'Enforce the layer / import boundary rules (alias-aware).', usage: BOUNDARIES_CHECK_USAGE },
1896
+ { name: 'wiring', description: 'The cross-file "declared but not wired" completeness gate (wiringRules[]).', usage: WIRING_CHECK_USAGE },
1897
+ {
1898
+ name: 'orphans',
1899
+ description: 'After a delete: surviving importers of the removed files / exports (alias-resolved).',
1900
+ usage: 'shrk check orphans [--since <ref>] [--staged] [--allow-empty] [--json]',
1901
+ },
1902
+ {
1903
+ name: 'imports',
1904
+ aliases: ['import-hygiene'],
1905
+ description: 'Import hygiene over the changed or selected files.',
1906
+ usage: 'shrk check imports [--changed-only | --since <ref> | --staged | --files a,b] [--emit-allowlist [--emit-allowlist-kind <kind>]] [--only-allowlist-candidates] [--fail-on-unexplained-allowlist] [--allow-empty] [--json]',
1907
+ },
1908
+ {
1909
+ name: 'registry-lifecycle',
1910
+ description: 'Registry lifecycle symmetry across the tree (register ↔ unregister).',
1911
+ usage: 'shrk check registry-lifecycle [--scope <glob>] [--changed-only | --since <ref>] [--limit N] [--offset N] [--budget-ms N] [--allow-empty] [--json]',
1912
+ },
1913
+ {
1914
+ name: 'generation',
1915
+ description: 'Dry-run one template generation and check its target paths.',
1916
+ usage: 'shrk check generation <templateId> <name> [--var k=v ...] [--changed-only | --since <ref> | --staged | --files a,b] [--json]',
1917
+ positionals: PositionalMode.Free,
1918
+ },
1919
+ { name: 'packs', description: 'Validate the installed packs.', usage: 'shrk check packs [--strict] [--min-score <0-100>] [--allow-empty] [--json]' },
1920
+ { name: 'pipelines', description: 'Validate the pipelines.', usage: 'shrk check pipelines [--strict] [--min-score <0-100>] [--allow-empty] [--json]' },
1921
+ { name: 'knowledge', description: 'Validate the knowledge entries.', usage: 'shrk check knowledge [--strict] [--min-score <0-100>] [--allow-empty] [--json]' },
1922
+ { name: 'templates', description: 'Validate the templates.', usage: 'shrk check templates [--strict] [--min-score <0-100>] [--allow-empty] [--json]' },
1923
+ ],
944
1924
  description: 'Run SharkCraft-level validation across knowledge / rules / templates / pipelines / packs / action hints / doctor. `check boundaries [--watch [--paths a,b] [--debounce N] [--once]]` re-runs the boundary scan on file changes.',
945
- usage: 'shrk [--cwd <dir>] check [packs|pipelines|knowledge|generation|boundaries|imports|wiring|orphans] [--strict] [--min-score <0-100>] [--changed-only] [--since <ref>] [--staged] [--only <ids>] [--json] [--watch [--paths <list>] [--debounce N] [--once]]',
1925
+ usage: 'shrk [--cwd <dir>] check [packs|pipelines|knowledge|templates|generation|boundaries|imports|wiring|orphans|registry-lifecycle] [--strict] [--min-score <0-100>] [--changed-only] [--since <ref>] [--staged] [--only <ids>] [--allow-empty] [--json] [--watch [--paths <list>] [--debounce N] [--once]]',
1926
+ // `--allow-empty` is the shared verdict valve (wiring / orphans / boundaries):
1927
+ // declared boolean so it can never swallow the positional subverb that
1928
+ // follows it. The boundary toggles (round 11) likewise never take a value.
1929
+ booleanFlags: new Set([ALLOW_EMPTY_FLAG, 'include-comments', 'fail-on-dead-units', 'no-rule-escalation']),
946
1930
  async run(args) {
947
1931
  const sub = args.positional[0];
948
1932
  // `check generation <id> <name>` legitimately takes extra positionals;
@@ -967,83 +1951,35 @@ export const checkCommand = {
967
1951
  return checkWiring(args);
968
1952
  if (sub === 'orphans')
969
1953
  return checkOrphans(args);
970
- if (sub === 'registry-lifecycle') {
971
- const cwd = resolveCwd(args);
972
- const changedOnly = flagBool(args, 'changed-only');
973
- const since = flagString(args, 'since');
974
- const scope = flagString(args, 'scope');
975
- const { buildRegistryLifecycleReport, renderRegistryLifecycleReportText } = await import('@shrkcrft/inspector');
976
- // `--changed-only` scopes the scan to the diff (tracked + untracked), so it
977
- // runs inline in seconds; the full-tree scan is bounded by a wall-clock
978
- // budget and flushes partial results on timeout instead of hanging.
979
- let files;
980
- if (changedOnly || since) {
981
- const changed = resolveChangedFiles({
982
- projectRoot: cwd,
983
- ...(since ? { since } : {}),
984
- ...(changedOnly && !since ? { includeWorktree: true } : {}),
985
- });
986
- files = changed.files;
987
- }
988
- // Full-tree walk honors the project's skipDirs override (so a repo that
989
- // registers code under tools/ etc. isn't blinded by the default set).
990
- let skipDirs;
991
- if (files === undefined) {
992
- const loaded = await resolveProjectConfig(cwd);
993
- if (loaded.ok)
994
- skipDirs = loaded.value.config.registryLifecycle?.skipDirs;
995
- }
996
- // Only the slow full-tree path needs the heartbeat (JSON keeps stdout clean).
997
- if (!flagBool(args, 'json') && files === undefined) {
998
- process.stderr.write('⏳ Scanning source + registries for lifecycle coverage (bounded by a wall-clock budget)…\n');
999
- }
1000
- const report = buildRegistryLifecycleReport({
1001
- projectRoot: cwd,
1002
- ...(files !== undefined ? { files } : {}),
1003
- ...(scope ? { scope } : {}),
1004
- ...(skipDirs ? { skipDirs } : {}),
1005
- });
1006
- // Honest exit-code contract (a25 §1 / §2.5):
1007
- // timedOut → NOT verified (2): the budget flushed partial results.
1008
- // registersFound=0 → NOT verified (2): nothing to check in scope — a
1009
- // "nothing to verify" run must never read as a green
1010
- // pass (an agent's `&& next` would march past it).
1011
- // missingRemovers>0 → failure (1).
1012
- // otherwise → verified pass (0).
1013
- const exit = report.timedOut || report.registersFound === 0
1014
- ? ExitCode.NotVerified
1015
- : report.missingRemovers.length === 0
1016
- ? ExitCode.VerifiedPass
1017
- : ExitCode.Failure;
1018
- if (flagBool(args, 'json')) {
1019
- process.stdout.write(asJson(report) + '\n');
1020
- return exit;
1021
- }
1022
- process.stdout.write(renderRegistryLifecycleReportText(report));
1023
- return exit;
1024
- }
1954
+ // One body for both lifecycle verbs (registry-lifecycle-run.ts): engine-
1955
+ // owned coverage settled through the gate envelope — a capped / over-budget
1956
+ // / interrupted scan is 2 with the `--offset` that reaches the remainder.
1957
+ if (sub === 'registry-lifecycle')
1958
+ return runRegistryLifecycle(args, 'check registry-lifecycle');
1025
1959
  const inspection = await inspectSharkcraft({ cwd: resolveCwd(args) });
1026
1960
  const readiness = buildAiReadinessReport(inspection);
1027
1961
  const readinessLine = `AI-readiness: ${readiness.score}/100 (${readiness.grade})`;
1962
+ // One group requested alone settles against that group's own doctor
1963
+ // coverage (`alone`): `check packs` over zero packs is 2, as `packs doctor`.
1028
1964
  if (sub === 'packs') {
1029
- return renderReport(args, [packsGroup(inspection)], readinessLine);
1965
+ return renderReport(args, [await packsGroup(inspection, args)], readinessLine, true);
1030
1966
  }
1031
1967
  if (sub === 'pipelines') {
1032
- return renderReport(args, [pipelinesGroup(inspection)], readinessLine);
1968
+ return renderReport(args, [pipelinesGroup(inspection, args)], readinessLine, true);
1033
1969
  }
1034
1970
  if (sub === 'knowledge') {
1035
- return renderReport(args, [knowledgeGroup(inspection)], readinessLine);
1971
+ return renderReport(args, [knowledgeGroup(inspection, args)], readinessLine, true);
1036
1972
  }
1037
1973
  if (sub === 'templates') {
1038
- return renderReport(args, [templatesGroup(inspection)], readinessLine);
1974
+ return renderReport(args, [templatesGroup(inspection, args)], readinessLine, true);
1039
1975
  }
1040
1976
  // Default: full sweep.
1041
1977
  return renderReport(args, [
1042
1978
  doctorGroup(inspection),
1043
- knowledgeGroup(inspection),
1044
- templatesGroup(inspection),
1045
- pipelinesGroup(inspection),
1046
- packsGroup(inspection),
1979
+ knowledgeGroup(inspection, args),
1980
+ templatesGroup(inspection, args),
1981
+ pipelinesGroup(inspection, args),
1982
+ await packsGroup(inspection, args),
1047
1983
  actionHintsGroup(inspection),
1048
1984
  ], readinessLine);
1049
1985
  },