@intentic/sandbox-contract 1.176.3 → 1.209.0

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 (334) hide show
  1. package/README.md +65 -0
  2. package/dist/agent-catalog.d.ts +15 -2
  3. package/dist/agent-catalog.d.ts.map +1 -1
  4. package/dist/agent-catalog.js +50 -1
  5. package/dist/agent-catalog.js.map +1 -1
  6. package/dist/agent-run-model.d.ts +3 -0
  7. package/dist/agent-run-model.d.ts.map +1 -0
  8. package/dist/agent-run-model.js +10 -0
  9. package/dist/agent-run-model.js.map +1 -0
  10. package/dist/capability-env.d.ts +2 -0
  11. package/dist/capability-env.d.ts.map +1 -0
  12. package/dist/capability-env.js +2 -0
  13. package/dist/capability-env.js.map +1 -0
  14. package/dist/chores/chores.d.ts +9 -0
  15. package/dist/chores/chores.d.ts.map +1 -1
  16. package/dist/chores/chores.js +257 -18
  17. package/dist/chores/chores.js.map +1 -1
  18. package/dist/chores/digest.js +0 -0
  19. package/dist/chores/digest.js.map +1 -1
  20. package/dist/chores/extension-update.d.ts +9 -0
  21. package/dist/chores/extension-update.d.ts.map +1 -0
  22. package/dist/chores/extension-update.js +15 -0
  23. package/dist/chores/extension-update.js.map +1 -0
  24. package/dist/chores/fix-deps.d.ts +10 -0
  25. package/dist/chores/fix-deps.d.ts.map +1 -0
  26. package/dist/chores/fix-deps.js +18 -0
  27. package/dist/chores/fix-deps.js.map +1 -0
  28. package/dist/chores/index.d.ts +7 -2
  29. package/dist/chores/index.d.ts.map +1 -1
  30. package/dist/chores/index.js +4 -1
  31. package/dist/chores/index.js.map +1 -1
  32. package/dist/chores/probes.d.ts.map +1 -1
  33. package/dist/chores/probes.js +127 -3
  34. package/dist/chores/probes.js.map +1 -1
  35. package/dist/chores/stack.d.ts +27 -0
  36. package/dist/chores/stack.d.ts.map +1 -0
  37. package/dist/chores/stack.js +121 -0
  38. package/dist/chores/stack.js.map +1 -0
  39. package/dist/chores/verdict.d.ts +2 -1
  40. package/dist/chores/verdict.d.ts.map +1 -1
  41. package/dist/chores/verdict.js +53 -6
  42. package/dist/chores/verdict.js.map +1 -1
  43. package/dist/contract-lock.d.ts +3 -0
  44. package/dist/contract-lock.d.ts.map +1 -0
  45. package/dist/contract-lock.js +35 -0
  46. package/dist/contract-lock.js.map +1 -0
  47. package/dist/contracts/activity.contract.d.ts +10 -0
  48. package/dist/contracts/activity.contract.d.ts.map +1 -1
  49. package/dist/contracts/agent.contract.d.ts +230 -20
  50. package/dist/contracts/agent.contract.d.ts.map +1 -1
  51. package/dist/contracts/agent.contract.js +2 -1
  52. package/dist/contracts/agent.contract.js.map +1 -1
  53. package/dist/contracts/agents.contract.d.ts +847 -1
  54. package/dist/contracts/agents.contract.d.ts.map +1 -1
  55. package/dist/contracts/agents.contract.js +3 -1
  56. package/dist/contracts/agents.contract.js.map +1 -1
  57. package/dist/contracts/automations.contract.d.ts +89 -0
  58. package/dist/contracts/automations.contract.d.ts.map +1 -1
  59. package/dist/contracts/automations.contract.js +3 -1
  60. package/dist/contracts/automations.contract.js.map +1 -1
  61. package/dist/contracts/capabilities.contract.d.ts +108 -32
  62. package/dist/contracts/capabilities.contract.d.ts.map +1 -1
  63. package/dist/contracts/capabilities.contract.js +5 -1
  64. package/dist/contracts/capabilities.contract.js.map +1 -1
  65. package/dist/contracts/chores.contract.d.ts +43 -0
  66. package/dist/contracts/chores.contract.d.ts.map +1 -1
  67. package/dist/contracts/ci.contract.d.ts +5 -0
  68. package/dist/contracts/ci.contract.d.ts.map +1 -1
  69. package/dist/contracts/ci.contract.js +2 -2
  70. package/dist/contracts/ci.contract.js.map +1 -1
  71. package/dist/contracts/claude.contract.d.ts +3 -14
  72. package/dist/contracts/claude.contract.d.ts.map +1 -1
  73. package/dist/contracts/claude.contract.js +2 -3
  74. package/dist/contracts/claude.contract.js.map +1 -1
  75. package/dist/contracts/drafts.contract.d.ts +4 -0
  76. package/dist/contracts/drafts.contract.d.ts.map +1 -1
  77. package/dist/contracts/endpoints.contract.d.ts +28 -12
  78. package/dist/contracts/endpoints.contract.d.ts.map +1 -1
  79. package/dist/contracts/endpoints.contract.js +9 -0
  80. package/dist/contracts/endpoints.contract.js.map +1 -1
  81. package/dist/contracts/extensions.contract.d.ts +399 -69
  82. package/dist/contracts/extensions.contract.d.ts.map +1 -1
  83. package/dist/contracts/extensions.contract.js +15 -1
  84. package/dist/contracts/extensions.contract.js.map +1 -1
  85. package/dist/contracts/git.contract.d.ts +209 -11
  86. package/dist/contracts/git.contract.d.ts.map +1 -1
  87. package/dist/contracts/git.contract.js +12 -2
  88. package/dist/contracts/git.contract.js.map +1 -1
  89. package/dist/contracts/grok.contract.d.ts +0 -13
  90. package/dist/contracts/grok.contract.d.ts.map +1 -1
  91. package/dist/contracts/grok.contract.js +1 -2
  92. package/dist/contracts/grok.contract.js.map +1 -1
  93. package/dist/contracts/host.contract.d.ts +39 -0
  94. package/dist/contracts/host.contract.d.ts.map +1 -1
  95. package/dist/contracts/host.contract.js +3 -2
  96. package/dist/contracts/host.contract.js.map +1 -1
  97. package/dist/contracts/loops.contract.d.ts +343 -0
  98. package/dist/contracts/loops.contract.d.ts.map +1 -0
  99. package/dist/contracts/loops.contract.js +11 -0
  100. package/dist/contracts/loops.contract.js.map +1 -0
  101. package/dist/contracts/panels.contract.d.ts +6 -0
  102. package/dist/contracts/panels.contract.d.ts.map +1 -1
  103. package/dist/contracts/personas.contract.d.ts +112 -0
  104. package/dist/contracts/personas.contract.d.ts.map +1 -0
  105. package/dist/contracts/personas.contract.js +13 -0
  106. package/dist/contracts/personas.contract.js.map +1 -0
  107. package/dist/contracts/ports.contract.d.ts +1 -0
  108. package/dist/contracts/ports.contract.d.ts.map +1 -1
  109. package/dist/contracts/prepush.contract.d.ts +1 -0
  110. package/dist/contracts/prepush.contract.d.ts.map +1 -1
  111. package/dist/contracts/prepush.contract.js.map +1 -1
  112. package/dist/contracts/{codex.contract.d.ts → providers.contract.d.ts} +11 -3
  113. package/dist/contracts/providers.contract.d.ts.map +1 -0
  114. package/dist/contracts/providers.contract.js +6 -0
  115. package/dist/contracts/providers.contract.js.map +1 -0
  116. package/dist/contracts/public.contract.d.ts +24 -0
  117. package/dist/contracts/public.contract.d.ts.map +1 -0
  118. package/dist/contracts/public.contract.js +8 -0
  119. package/dist/contracts/public.contract.js.map +1 -0
  120. package/dist/contracts/push.contract.d.ts +3 -3
  121. package/dist/contracts/secrets.contract.d.ts +9 -0
  122. package/dist/contracts/secrets.contract.d.ts.map +1 -1
  123. package/dist/contracts/sessions.contract.d.ts +16 -1
  124. package/dist/contracts/sessions.contract.d.ts.map +1 -1
  125. package/dist/contracts/sessions.contract.js +1 -1
  126. package/dist/contracts/sessions.contract.js.map +1 -1
  127. package/dist/contracts/settings.contract.d.ts +352 -58
  128. package/dist/contracts/settings.contract.d.ts.map +1 -1
  129. package/dist/contracts/settings.contract.js +2 -1
  130. package/dist/contracts/settings.contract.js.map +1 -1
  131. package/dist/contracts/share.contract.d.ts +55 -0
  132. package/dist/contracts/share.contract.d.ts.map +1 -0
  133. package/dist/contracts/share.contract.js +9 -0
  134. package/dist/contracts/share.contract.js.map +1 -0
  135. package/dist/contracts/skills.contract.d.ts +41 -0
  136. package/dist/contracts/skills.contract.d.ts.map +1 -0
  137. package/dist/contracts/skills.contract.js +9 -0
  138. package/dist/contracts/skills.contract.js.map +1 -0
  139. package/dist/contracts/system.contract.d.ts +204 -8
  140. package/dist/contracts/system.contract.d.ts.map +1 -1
  141. package/dist/contracts/system.contract.js +10 -2
  142. package/dist/contracts/system.contract.js.map +1 -1
  143. package/dist/contracts/workflows.contract.d.ts +575 -0
  144. package/dist/contracts/workflows.contract.d.ts.map +1 -0
  145. package/dist/contracts/workflows.contract.js +13 -0
  146. package/dist/contracts/workflows.contract.js.map +1 -0
  147. package/dist/contracts/workspace.contract.d.ts +18 -4
  148. package/dist/contracts/workspace.contract.d.ts.map +1 -1
  149. package/dist/contracts/workspace.contract.js +6 -3
  150. package/dist/contracts/workspace.contract.js.map +1 -1
  151. package/dist/conversation-ids.d.ts +2 -0
  152. package/dist/conversation-ids.d.ts.map +1 -0
  153. package/dist/conversation-ids.js +113 -0
  154. package/dist/conversation-ids.js.map +1 -0
  155. package/dist/events.d.ts +523 -17
  156. package/dist/events.d.ts.map +1 -1
  157. package/dist/events.js +106 -13
  158. package/dist/events.js.map +1 -1
  159. package/dist/history-state.d.ts.map +1 -1
  160. package/dist/history-state.js +10 -0
  161. package/dist/history-state.js.map +1 -1
  162. package/dist/hostnames.d.ts +4 -0
  163. package/dist/hostnames.d.ts.map +1 -1
  164. package/dist/hostnames.js +7 -2
  165. package/dist/hostnames.js.map +1 -1
  166. package/dist/index.d.ts +3791 -478
  167. package/dist/index.d.ts.map +1 -1
  168. package/dist/index.js +39 -17
  169. package/dist/index.js.map +1 -1
  170. package/dist/listener-protocol.d.ts +77 -0
  171. package/dist/listener-protocol.d.ts.map +1 -0
  172. package/dist/listener-protocol.js +33 -0
  173. package/dist/listener-protocol.js.map +1 -0
  174. package/dist/model-order.d.ts +1 -0
  175. package/dist/model-order.d.ts.map +1 -1
  176. package/dist/model-order.js +16 -0
  177. package/dist/model-order.js.map +1 -1
  178. package/dist/output-fields.d.ts +30 -0
  179. package/dist/output-fields.d.ts.map +1 -0
  180. package/dist/output-fields.js +57 -0
  181. package/dist/output-fields.js.map +1 -0
  182. package/dist/path-refs.d.ts.map +1 -1
  183. package/dist/path-refs.js.map +1 -1
  184. package/dist/publish-drafts.d.ts +7 -0
  185. package/dist/publish-drafts.d.ts.map +1 -0
  186. package/dist/publish-drafts.js +20 -0
  187. package/dist/publish-drafts.js.map +1 -0
  188. package/dist/quick-model.d.ts +2 -1
  189. package/dist/quick-model.d.ts.map +1 -1
  190. package/dist/quick-model.js +18 -14
  191. package/dist/quick-model.js.map +1 -1
  192. package/dist/routes.d.ts +3 -0
  193. package/dist/routes.d.ts.map +1 -1
  194. package/dist/routes.js +63 -0
  195. package/dist/routes.js.map +1 -1
  196. package/dist/runtime-state.d.ts +33 -0
  197. package/dist/runtime-state.d.ts.map +1 -0
  198. package/dist/runtime-state.js +14 -0
  199. package/dist/runtime-state.js.map +1 -0
  200. package/dist/schemas.d.ts +4886 -851
  201. package/dist/schemas.d.ts.map +1 -1
  202. package/dist/schemas.js +993 -192
  203. package/dist/schemas.js.map +1 -1
  204. package/dist/search-globs.d.ts +6 -0
  205. package/dist/search-globs.d.ts.map +1 -0
  206. package/dist/search-globs.js +39 -0
  207. package/dist/search-globs.js.map +1 -0
  208. package/dist/share-paths.d.ts +8 -0
  209. package/dist/share-paths.d.ts.map +1 -0
  210. package/dist/share-paths.js +21 -0
  211. package/dist/share-paths.js.map +1 -0
  212. package/dist/title.d.ts.map +1 -1
  213. package/dist/title.js +1 -1
  214. package/dist/title.js.map +1 -1
  215. package/dist/tunnel-ids.d.ts +1 -0
  216. package/dist/tunnel-ids.d.ts.map +1 -1
  217. package/dist/tunnel-ids.js +1 -0
  218. package/dist/tunnel-ids.js.map +1 -1
  219. package/dist/versions.d.ts +4 -0
  220. package/dist/versions.d.ts.map +1 -0
  221. package/dist/versions.js +15 -0
  222. package/dist/versions.js.map +1 -0
  223. package/dist/workflow-faults.d.ts +4 -0
  224. package/dist/workflow-faults.d.ts.map +1 -0
  225. package/dist/workflow-faults.js +115 -0
  226. package/dist/workflow-faults.js.map +1 -0
  227. package/dist/workspace-state.d.ts +280 -1
  228. package/dist/workspace-state.d.ts.map +1 -1
  229. package/dist/workspace-state.js +163 -21
  230. package/dist/workspace-state.js.map +1 -1
  231. package/package.json +30 -6
  232. package/src/agent-catalog.test.ts +147 -7
  233. package/src/agent-catalog.ts +233 -21
  234. package/src/agent-run-model.test.ts +60 -0
  235. package/src/agent-run-model.ts +51 -0
  236. package/src/capability-env.ts +10 -0
  237. package/src/capability-ledger.test.ts +110 -0
  238. package/src/chores/chores.test.ts +32 -0
  239. package/src/chores/chores.ts +456 -32
  240. package/src/chores/digest.ts +0 -0
  241. package/src/chores/extension-update.ts +37 -0
  242. package/src/chores/fix-deps.ts +40 -0
  243. package/src/chores/index.ts +7 -2
  244. package/src/chores/probes.test.ts +155 -3
  245. package/src/chores/probes.ts +200 -4
  246. package/src/chores/stack.test.ts +126 -0
  247. package/src/chores/stack.ts +244 -0
  248. package/src/chores/verdict.test.ts +357 -19
  249. package/src/chores/verdict.ts +95 -21
  250. package/src/contract-lock.test.ts +17 -0
  251. package/src/contract-lock.ts +66 -0
  252. package/src/contracts/agent.contract.ts +6 -0
  253. package/src/contracts/agents.contract.ts +12 -0
  254. package/src/contracts/automations.contract.ts +17 -1
  255. package/src/contracts/capabilities.contract.ts +22 -0
  256. package/src/contracts/ci.contract.ts +10 -2
  257. package/src/contracts/claude.contract.ts +8 -4
  258. package/src/contracts/endpoints.contract.ts +21 -0
  259. package/src/contracts/extensions.contract.ts +42 -0
  260. package/src/contracts/git.contract.ts +33 -6
  261. package/src/contracts/grok.contract.ts +4 -3
  262. package/src/contracts/host.contract.ts +13 -2
  263. package/src/contracts/loops.contract.ts +62 -0
  264. package/src/contracts/personas.contract.ts +56 -0
  265. package/src/contracts/prepush.contract.ts +4 -3
  266. package/src/contracts/providers.contract.ts +23 -0
  267. package/src/contracts/public.contract.ts +14 -0
  268. package/src/contracts/sessions.contract.ts +5 -1
  269. package/src/contracts/settings.contract.ts +5 -0
  270. package/src/contracts/share.contract.ts +20 -0
  271. package/src/contracts/skills.contract.ts +24 -0
  272. package/src/contracts/system.contract.ts +27 -3
  273. package/src/contracts/workflows.contract.ts +78 -0
  274. package/src/contracts/workspace.contract.ts +10 -2
  275. package/src/conversation-ids.test.ts +25 -0
  276. package/src/conversation-ids.ts +160 -0
  277. package/src/events.test.ts +20 -1
  278. package/src/events.ts +404 -43
  279. package/src/history-state.ts +30 -1
  280. package/src/hostnames.ts +20 -6
  281. package/src/index.ts +58 -17
  282. package/src/listener-protocol.ts +96 -0
  283. package/src/model-order.test.ts +32 -1
  284. package/src/model-order.ts +56 -3
  285. package/src/output-fields.ts +111 -0
  286. package/src/path-refs.test.ts +4 -4
  287. package/src/path-refs.ts +5 -5
  288. package/src/publish-drafts.ts +59 -0
  289. package/src/quick-model.test.ts +70 -27
  290. package/src/quick-model.ts +76 -40
  291. package/src/routes.test.ts +91 -3
  292. package/src/routes.ts +135 -0
  293. package/src/runtime-state.test.ts +45 -0
  294. package/src/runtime-state.ts +124 -0
  295. package/src/schemas.test.ts +16 -29
  296. package/src/schemas.ts +3475 -552
  297. package/src/search-globs.test.ts +46 -0
  298. package/src/search-globs.ts +76 -0
  299. package/src/share-paths.test.ts +48 -0
  300. package/src/share-paths.ts +68 -0
  301. package/src/state-portability.ts +8 -8
  302. package/src/title.test.ts +20 -1
  303. package/src/title.ts +12 -3
  304. package/src/tunnel-ids.ts +15 -1
  305. package/src/versions.test.ts +39 -0
  306. package/src/versions.ts +48 -0
  307. package/src/workflow-faults.test.ts +127 -0
  308. package/src/workflow-faults.ts +188 -0
  309. package/src/workspace-state.test.ts +169 -15
  310. package/src/workspace-state.ts +427 -35
  311. package/dist/contracts/codex.contract.d.ts.map +0 -1
  312. package/dist/contracts/codex.contract.js +0 -6
  313. package/dist/contracts/codex.contract.js.map +0 -1
  314. package/dist/contracts/gemini.contract.d.ts +0 -16
  315. package/dist/contracts/gemini.contract.d.ts.map +0 -1
  316. package/dist/contracts/gemini.contract.js +0 -6
  317. package/dist/contracts/gemini.contract.js.map +0 -1
  318. package/dist/contracts/kimi.contract.d.ts +0 -16
  319. package/dist/contracts/kimi.contract.d.ts.map +0 -1
  320. package/dist/contracts/kimi.contract.js +0 -6
  321. package/dist/contracts/kimi.contract.js.map +0 -1
  322. package/dist/contracts/komodo.contract.d.ts +0 -125
  323. package/dist/contracts/komodo.contract.d.ts.map +0 -1
  324. package/dist/contracts/komodo.contract.js +0 -14
  325. package/dist/contracts/komodo.contract.js.map +0 -1
  326. package/dist/contracts/memory.contract.d.ts +0 -34
  327. package/dist/contracts/memory.contract.d.ts.map +0 -1
  328. package/dist/contracts/memory.contract.js +0 -9
  329. package/dist/contracts/memory.contract.js.map +0 -1
  330. package/src/contracts/codex.contract.ts +0 -10
  331. package/src/contracts/gemini.contract.ts +0 -11
  332. package/src/contracts/kimi.contract.ts +0 -9
  333. package/src/contracts/komodo.contract.ts +0 -36
  334. package/src/contracts/memory.contract.ts +0 -13
@@ -1,6 +1,7 @@
1
1
  import type { Advisory, ChoreSignals, OutdatedPackage, ProbeId, ProbeResult } from "../schemas.js";
2
2
  import { bucketOf, digestOf } from "./digest.js";
3
3
  import { CHORE_INVARIANTS, composeAsk, REPORT_INVARIANTS, TRIAGE_NOTE } from "./prompt.js";
4
+ import { componentStem, frameworksOf, idiomRule, normalizePath, UI_FRAMEWORKS, usesTailwind } from "./stack.js";
4
5
 
5
6
  /* THE CHORE BOOK — what routine maintenance a repository is owed, and what has to be TRUE before we say so.
6
7
  *
@@ -36,6 +37,11 @@ import { CHORE_INVARIANTS, composeAsk, REPORT_INVARIANTS, TRIAGE_NOTE } from "./
36
37
 
37
38
  export type ChoreStance = "act" | "report";
38
39
 
40
+ /* WHAT KIND OF CLAIM A CHORE MAKES ON SOMEONE'S ATTENTION. Four of them, ordered from "this is a risk you are
41
+ * carrying right now" to "this is worth thinking about this quarter" — see CHORE_KINDS at the foot of this file,
42
+ * which carries the argument and the words the panel groups under. */
43
+ export type ChoreKind = "carrying" | "accruing" | "drifting" | "surveying";
44
+
39
45
  export interface ChoreContext {
40
46
  // Root-relative repo dir; the empty string is the workspace's own root repo.
41
47
  readonly repo: string;
@@ -70,6 +76,11 @@ export interface Chore {
70
76
  readonly icon: string;
71
77
  // The one-line standing description, shown whether or not the chore is currently due.
72
78
  readonly description: string;
79
+ /* WHICH OF THE FOUR KINDS OF CLAIM THIS IS (CHORE_KINDS, at the foot of this file). It decides the book's
80
+ * order and the panel's grouping, and it is a FIELD rather than a comment above the array for exactly that
81
+ * reason: the reading order is the one editorial claim this surface makes, and a claim spelled as a comment
82
+ * beside a hand-maintained list is one nobody can check and the compiler cannot keep. */
83
+ readonly kind: ChoreKind;
73
84
  /* THE RULE, in words — what has to be true for this chore to be due, stated so a reader can check it against
74
85
  * the evidence below it and disagree.
75
86
  *
@@ -82,14 +93,21 @@ export interface Chore {
82
93
  * cannot check English. The rule for writing one: say the THRESHOLD, not the subject. "Duplication is high"
83
94
  * is a topic; "more than 5% of the tree is duplicated" is a criterion. */
84
95
  readonly criterion: string;
85
- /* WHETHER THIS IS A QUESTION WORTH ASKING OF THIS REPOSITORY AT ALL — returns undefined when it is, and a
86
- * plain-language reason when it is not ("this repository ships no Dockerfile").
96
+ /* WHETHER THIS IS A QUESTION WORTH ASKING OF THIS REPOSITORY AT ALL — returns undefined when it is, and what
97
+ * is MISSING when it is not.
87
98
  *
88
99
  * Distinct from `assess`, and the distinction is the whole point: `assess` asks whether the answer is yes,
89
100
  * this asks whether the question makes sense. "Re-read the documentation against the code" in a repository
90
101
  * with no documentation is not a chore that is currently clear — it is one that will never apply here, and
91
102
  * showing it as clear says we checked something we cannot check. A chore that does not apply is dropped from
92
- * the panel entirely; only a footer records that it was considered.
103
+ * the panel entirely; a line in the scope strip records that it was considered.
104
+ *
105
+ * A BARE CAUSE — "no Dockerfile", never "this repository ships no Dockerfile, so there is no image to slim".
106
+ * Same spelling as `ProbeSpec.unavailable`, and for the same reason both surfaces need: one absent
107
+ * package.json rules out five chores, and five sentences saying so at length is the wall of text this phrasing
108
+ * exists to prevent. The panel groups by this string, so the CONSEQUENCE — which chores it costs — is the list
109
+ * beside it rather than a clause repeated inside every entry. Identical causes must be spelled identically or
110
+ * they group apart.
93
111
  *
94
112
  * Reads `signals` rather than probes on purpose: applicability is about what the repository IS, which is a
95
113
  * fact the daemon holds without measuring anything. If a gate needed a probe it would be describing the
@@ -152,6 +170,11 @@ const JSCPD_REPORT = `${JSCPD_DIR}/jscpd-report.json`;
152
170
  // called "root" — so it is spelled out here, once, rather than at every call site that builds a prompt.
153
171
  export const repoLabel = (repo: string): string => (repo === `root` || repo === `` ? `the workspace root repository` : repo);
154
172
 
173
+ // The same repository, named for a surface that has a 16rem column or a chip to say it in. `repoLabel` is prose
174
+ // and reads as prose inside a sentence ("update dependencies in the workspace root repository"); a rail row wants
175
+ // the name on its own, and "the workspace root repository" truncates to "the workspace root reposi…" there.
176
+ export const repoName = (repo: string): string => (repo === `root` || repo === `` ? `workspace root` : repo);
177
+
155
178
  const plural = (count: number, one: string, many = `${one}s`): string => `${count} ${count === 1 ? one : many}`;
156
179
 
157
180
  // One outdated dependency, as the panel lists it. The semver step leads, because it is what decides whether the
@@ -182,8 +205,9 @@ const security: Chore = {
182
205
  title: `Patch security advisories`,
183
206
  icon: `shield`,
184
207
  description: `Published advisories against this dependency tree, and the ones whose fix is a version bump.`,
208
+ kind: `carrying`,
185
209
  criterion: `pnpm audit reports an advisory of high or critical severity against the resolved tree.`,
186
- applies: (signals) => (signals.shape.lockfile ? undefined : `there is no lockfile here, so nothing resolves to a tree that could be audited`),
210
+ applies: (signals) => (signals.shape.lockfile ? undefined : `no lockfile`),
187
211
  stance: `act`,
188
212
  needs: [`audit`],
189
213
  cadenceMs: 0,
@@ -211,7 +235,10 @@ const security: Chore = {
211
235
  headline: `${plural(blocking.length, `advisory`, `advisories`)}, ${patchable.length} with a published fix`,
212
236
  detail: blocking
213
237
  .toSorted((left, right) => left.name.localeCompare(right.name))
214
- .map((advisory) => `${advisory.severity} · ${advisory.name} — ${advisory.title}${advisory.patched === undefined ? ` (no patch yet)` : ``}`),
238
+ .map(
239
+ (advisory) =>
240
+ `${advisory.severity} · ${advisory.name} — ${advisory.title}${advisory.patched === undefined ? ` (no patch yet)` : ``}`,
241
+ ),
215
242
  // Identities, not counts: every advisory that appears or is fixed is genuinely news, and there is no
216
243
  // ordinary drift here to absorb.
217
244
  digest: digestOf(...blocking.map((advisory) => `${advisory.name}@${advisory.severity}`).toSorted()),
@@ -245,8 +272,9 @@ const dependencies: Chore = {
245
272
  title: `Update dependencies`,
246
273
  icon: `arrow-circle-up`,
247
274
  description: `How far behind the registry this tree has drifted, and which majors are waiting.`,
275
+ kind: `accruing`,
248
276
  criterion: `A dependency is a major version behind, or more than 20 are behind by any amount.`,
249
- applies: (signals) => (signals.shape.packageManifest ? undefined : `this repository has no package.json, so there is no npm dependency tree to be behind`),
277
+ applies: (signals) => (signals.shape.packageManifest ? undefined : `no package.json`),
250
278
  stance: `act`,
251
279
  needs: [`outdated`],
252
280
  cadenceMs: 30 * DAY_MS,
@@ -294,8 +322,9 @@ const deadCode: Chore = {
294
322
  title: `Clear out dead code`,
295
323
  icon: `trash`,
296
324
  description: `Files, exports and dependencies nothing in this repository references any more.`,
325
+ kind: `accruing`,
297
326
  criterion: `knip reports at least one unreferenced file, export or dependency.`,
298
- applies: (signals) => (signals.shape.packageManifest ? undefined : `this repository is not a Node project, and knip only reads those`),
327
+ applies: (signals) => (signals.shape.packageManifest ? undefined : `no package.json`),
299
328
  stance: `act`,
300
329
  needs: [`knip`],
301
330
  cadenceMs: 14 * DAY_MS,
@@ -358,6 +387,7 @@ const duplication: Chore = {
358
387
  title: `Find duplication worth collapsing`,
359
388
  icon: `clone`,
360
389
  description: `Copy-paste that has grown past a fifth of a percent of the tree. Reports only — extracting is a design call.`,
390
+ kind: `drifting`,
361
391
  criterion: `jscpd reports more than 5% of the scanned tree duplicated.`,
362
392
  stance: `report`,
363
393
  needs: [`jscpd`],
@@ -400,7 +430,9 @@ const duplication: Chore = {
400
430
  done: `Done when every clone in the report has either a named extraction or a one-line reason it should stay.`,
401
431
  };
402
432
 
403
- /* DOCUMENTATION. The evidence is a package with no architecture document, which sounds like a coverage statistic
433
+ /* DOCUMENTATION. The evidence is a package with no README which IS its architecture document in this
434
+ * workspace, so this is a stat on the package directory rather than a lookup in a parallel tree. It sounds like
435
+ * a coverage statistic
404
436
  * and would be one if the rail read it directly. It does not: the digest is the SET of undocumented package
405
437
  * directories, so a long-standing backlog goes quiet after it is seen once, and a package appearing that nothing
406
438
  * explains is an event that speaks. That is the whole difference between this being useful and being a nag. */
@@ -408,9 +440,10 @@ const documentation: Chore = {
408
440
  id: `documentation-refresh`,
409
441
  title: `Document what nothing explains`,
410
442
  icon: `file-edit`,
411
- description: `Packages in this repository with no architecture document — new ones first.`,
412
- criterion: `A workspace package has no docs/architecture document.`,
413
- applies: (signals) => (signals.packages.length > 0 ? undefined : `this repository is not a workspace, so it has no packages to document one by one`),
443
+ description: `Packages in this repository with no README — new ones first.`,
444
+ kind: `drifting`,
445
+ criterion: `A workspace package has no README.`,
446
+ applies: (signals) => (signals.packages.length > 0 ? undefined : `not a workspace`),
414
447
  stance: `act`,
415
448
  needs: [],
416
449
  cadenceMs: 90 * DAY_MS,
@@ -426,7 +459,7 @@ const documentation: Chore = {
426
459
  severity: `info`,
427
460
  why:
428
461
  `${plural(undocumented.length, `package`)} of ${context.signals.packages.length} in ${repoLabel(context.repo)} have no ` +
429
- `docs/architecture document: ${undocumented.map((entry) => entry.dir).join(`, `)}.`,
462
+ `README: ${undocumented.map((entry) => entry.dir).join(`, `)}.`,
430
463
  };
431
464
  },
432
465
  diagnosis: `A package nobody can read the shape of gets worked in by guesswork, and the guesses accumulate.`,
@@ -464,6 +497,7 @@ const complexity: Chore = {
464
497
  title: `Simplify what everything waits on`,
465
498
  icon: `wave-pulse`,
466
499
  description: `Files that both churn and carry the repository — where edits are slow and ripple outward.`,
500
+ kind: `accruing`,
467
501
  criterion: `A file in the hotspot ranking is also a key module, or its branching is three times the median of that ranking.`,
468
502
  stance: `act`,
469
503
  needs: [],
@@ -524,8 +558,9 @@ const runtime: Chore = {
524
558
  title: `Move off an end-of-life runtime`,
525
559
  icon: `bolt`,
526
560
  description: `Whether the Node this sandbox runs still receives security patches.`,
561
+ kind: `carrying`,
527
562
  criterion: `The Node release this sandbox runs is past its end-of-life date, or within 90 days of it.`,
528
- applies: (signals) => (signals.shape.packageManifest ? undefined : `this repository is not a Node project, so the sandbox's runtime is not its concern`),
563
+ applies: (signals) => (signals.shape.packageManifest ? undefined : `no package.json`),
529
564
  stance: `act`,
530
565
  needs: [],
531
566
  cadenceMs: 0,
@@ -544,7 +579,9 @@ const runtime: Chore = {
544
579
  // Which packages would have to be argued with, so the finding names the work rather than only the fact.
545
580
  const pinned = context.signals.packages.filter((entry) => entry.engines?.[`node`] !== undefined);
546
581
  return {
547
- headline: past ? `Node ${major} stopped receiving security patches ${days} days ago` : `Node ${major} reaches end of life in ${days} days`,
582
+ headline: past
583
+ ? `Node ${major} stopped receiving security patches ${days} days ago`
584
+ : `Node ${major} reaches end of life in ${days} days`,
548
585
  detail: [
549
586
  `running · ${context.node}`,
550
587
  `end of life · ${eol}`,
@@ -586,8 +623,9 @@ const libraries: Chore = {
586
623
  title: `Settle on one library per job`,
587
624
  icon: `box`,
588
625
  description: `Two dependencies solving the same problem — both shipped, both maintained, one picked at random.`,
626
+ kind: `drifting`,
589
627
  criterion: `Two or more installed dependencies do the same job.`,
590
- applies: (signals) => (signals.packages.length > 0 ? undefined : `this repository is not a workspace, so there are no package manifests to compare`),
628
+ applies: (signals) => (signals.packages.length > 0 ? undefined : `not a workspace`),
591
629
  stance: `report`,
592
630
  needs: [],
593
631
  cadenceMs: 90 * DAY_MS,
@@ -618,6 +656,351 @@ const libraries: Chore = {
618
656
  done: `Done when every overlapping pair has a recommendation with a call-site count behind it, or a reason the overlap is fine.`,
619
657
  };
620
658
 
659
+ /* ---- THE FRONT-END CHORES -------------------------------------------------------------------------------------
660
+ *
661
+ * Four chores that only exist where a UI framework does, kept together because they share one gate and one
662
+ * probe — and split across the reading order in CHORES, since where a row belongs is decided by what KIND of
663
+ * finding it is, not by which file paragraph it was written in.
664
+ *
665
+ * They gate on `shape.deps` rather than on `signals.packages`, and that is not interchangeable. `packages` is
666
+ * populated from pnpm-workspace.yaml, so it is EMPTY for a repository that is not a monorepo — which is what a
667
+ * Vite app, a Next app and an Angular CLI project all are. A framework gate reading it would be permanently dark
668
+ * in the overwhelming majority of the repositories these four were written for, and dark silently: the chores
669
+ * would not appear, the footer would say the repository has no packages, and nothing would look broken.
670
+ *
671
+ * All four also say something the rest of the book does not have to. A component, a class name and a bundle chunk
672
+ * are things nobody sees the whole of — you read one component at a time, and the tenth copy of a button looks
673
+ * exactly like the first nine did. That is the same argument the whole surface rests on, just further from the
674
+ * places a compiler will ever help. */
675
+
676
+ // How many rows of evidence a UI finding lists before it is a wall rather than a list. The standing count still
677
+ // leads the headline; this only bounds what is enumerated underneath it.
678
+ const DETAIL_LIMIT = 8;
679
+
680
+ const FRAMEWORK_LABELS = UI_FRAMEWORKS.map((framework) => framework.label).join(`, `);
681
+
682
+ // One gate, one cause, four chores. Built from the table so that a framework added to stack.ts cannot leave a
683
+ // stale list of names behind in a reason nobody re-reads.
684
+ const needsFramework = (signals: ChoreSignals): string | undefined =>
685
+ frameworksOf(signals.shape.deps).length > 0 ? undefined : `no ${FRAMEWORK_LABELS}`;
686
+
687
+ const bytesLabel = (bytes: number): string => (bytes >= 1024 * 1024 ? `${(bytes / (1024 * 1024)).toFixed(1)} MB` : `${Math.round(bytes / 1024)} kB`);
688
+
689
+ /* BUNDLE. What a browser downloads before anything appears, which is the fact about a front-end that is furthest
690
+ * from anything visible in an editor: every dependency looks the same size in an import statement.
691
+ *
692
+ * The criterion is a SHARE, and that is deliberate — it is the second exception to the book's leader-relative
693
+ * rule, and it earns the same defence duplication's 5% does. A byte threshold would need a different value for a
694
+ * marketing page and an IDE, would be argued about forever, and would be wrong the moment either one grew. "One
695
+ * chunk is more than half of everything you ship" needs no calibration: it says the build is not split, which is
696
+ * true or false at any size. A well-split app has its largest chunk well under this whatever it weighs, and a
697
+ * small app that genuinely is one chunk trips it and is right to — that IS its entire download.
698
+ *
699
+ * Report-stance. Where the split boundaries go is a routing and product decision, and an agent that lazily
700
+ * imported things unattended at three in the morning would be making it. */
701
+ const BUNDLE_SHARE_FLOOR = 50;
702
+ // Below this there is no ranking to be an outlier in — two files cannot tell you anything about how a build is
703
+ // divided, and the largest of them is over half by arithmetic rather than by fault.
704
+ const BUNDLE_MIN_ASSETS = 3;
705
+
706
+ /* An asset's name with its content hash taken out — `assets/vendor-DlAUqK2U.js` becomes `assets/vendor.js`.
707
+ *
708
+ * Without this the digest changes on every single build, because a content hash changing is the entire point of a
709
+ * content hash. The chore would badge after every `pnpm build` while reporting nothing new, which is precisely
710
+ * the lit-every-day failure the digest exists to prevent.
711
+ *
712
+ * Eight or more characters containing a digit, immediately before the final extension: long enough to leave
713
+ * `vendor-react.js` and `.min.js` alone, specific enough to catch Vite's `-DlAUqK2U` and webpack's `.9f2a1b0c`. */
714
+ const stableAsset = (path: string): string => path.replace(/[.-](?=[A-Za-z0-9_-]*[0-9])[A-Za-z0-9_-]{8,}(\.[a-z0-9]+)$/, `$1`);
715
+
716
+ const bundleWeight: Chore = {
717
+ id: `bundle-weight`,
718
+ title: `Split what the browser downloads first`,
719
+ icon: `download`,
720
+ description: `What the last build put on disk, and whether it arrives as one download or several.`,
721
+ kind: `accruing`,
722
+ criterion: `A single asset is more than half of the build's total transfer size.`,
723
+ applies: needsFramework,
724
+ stance: `report`,
725
+ needs: [`bundle`],
726
+ cadenceMs: 30 * DAY_MS,
727
+ assess: (context) => {
728
+ const facts = factsOf(context, `bundle`);
729
+ if (facts === undefined) {
730
+ return undefined;
731
+ }
732
+ const { assets, totalGzip, dir } = facts.bundle;
733
+ if (assets.length < BUNDLE_MIN_ASSETS || totalGzip === 0) {
734
+ return undefined;
735
+ }
736
+ // By GZIP, not by raw bytes. What is on disk is not what crosses the wire, and a large but highly
737
+ // compressible asset — a source map comment, a big JSON blob — is not the download this is about.
738
+ const ranked = assets.toSorted((left, right) => right.gzip - left.gzip);
739
+ const largest = ranked[0];
740
+ if (largest === undefined) {
741
+ return undefined;
742
+ }
743
+ const share = (largest.gzip / totalGzip) * 100;
744
+ if (share < BUNDLE_SHARE_FLOOR) {
745
+ return undefined;
746
+ }
747
+ return {
748
+ headline: `${largest.path} is ${Math.round(share)}% of the ${bytesLabel(totalGzip)} this build ships`,
749
+ detail: ranked
750
+ .slice(0, DETAIL_LIMIT)
751
+ .map((asset) => `${bytesLabel(asset.gzip)} gzipped · ${asset.path} (${bytesLabel(asset.bytes)} on disk)`),
752
+ // The bucketed total and the hash-stripped identities of the biggest chunks. A rebuild of the same
753
+ // code is silent; a new heavy chunk appearing, or the whole thing doubling, is not.
754
+ digest: digestOf(
755
+ `total:${bucketOf(totalGzip)}`,
756
+ ...ranked
757
+ .slice(0, 5)
758
+ .map((asset) => stableAsset(asset.path))
759
+ .toSorted(),
760
+ ),
761
+ // Not a risk being carried, however large. `warning` is reserved for something with a clock on it.
762
+ severity: `info`,
763
+ why:
764
+ `The build output in ${dir}/ of ${repoLabel(context.repo)} is ${bytesLabel(totalGzip)} gzipped across ` +
765
+ `${plural(assets.length, `asset`)}, and ${largest.path} alone is ${bytesLabel(largest.gzip)} of it — ${Math.round(share)}%. ` +
766
+ `The next largest are ${ranked
767
+ .slice(1, 4)
768
+ .map((asset) => `${asset.path} (${bytesLabel(asset.gzip)})`)
769
+ .join(`, `)}. ` +
770
+ `This is the last build someone ran, read off disk; nothing rebuilt it to measure.`,
771
+ };
772
+ },
773
+ diagnosis: `Everything in the first chunk is downloaded and parsed before anything renders, whether or not the visitor needed it.`,
774
+ goal:
775
+ `Find out what is actually IN the dominant chunk before proposing anything — the repository's own bundler can report this, and a ` +
776
+ `recommendation made without it is guesswork. Then report the split worth making: which routes or features could load on demand, ` +
777
+ `which dependencies are pulled in wholesale for one function, and which are only used behind an interaction nobody has yet had. ` +
778
+ `Name the boundary for each and estimate what it saves. Where the chunk is genuinely all first-paint code, say so and close it.`,
779
+ done: `Done when every recommendation names a specific import boundary and the bytes it would move out of the first download.`,
780
+ };
781
+
782
+ /* FRAMEWORK IDIOMS. A migration nobody finished, which is the most ordinary state for a front-end of any age: the
783
+ * new way arrived, the new files use it, and the old files keep working — so nothing ever forces the rest.
784
+ *
785
+ * The digest is the one place this chore differs in shape from its neighbours, and it has to. Digesting the file
786
+ * identities, the way the documentation chore does, would re-badge every time anyone touched any of two hundred
787
+ * files, because a migration in progress is a set that changes constantly. So it digests the BUCKETED COUNT per
788
+ * idiom instead: a kind of legacy code appearing where there was none speaks, real progress through a bucket
789
+ * speaks, and one more file drifting in or out of a set of two hundred does not. */
790
+ const frameworkIdiom: Chore = {
791
+ id: `framework-idiom`,
792
+ title: `Finish the framework migrations`,
793
+ icon: `history`,
794
+ description: `Code still written the way the framework used to recommend, long after it stopped.`,
795
+ kind: `accruing`,
796
+ criterion: `A file uses a framework idiom that framework's own maintainers have replaced.`,
797
+ applies: needsFramework,
798
+ stance: `act`,
799
+ needs: [`ui`],
800
+ cadenceMs: 60 * DAY_MS,
801
+ assess: (context) => {
802
+ const facts = factsOf(context, `ui`);
803
+ if (facts === undefined) {
804
+ return undefined;
805
+ }
806
+ /* Two rules are dropped rather than shown, and the second is the one that would have made this chore
807
+ * embarrassing.
808
+ *
809
+ * AN IDIOM THIS BUILD HAS NEVER HEARD OF. The daemon composes the sweep from its own copy of the table, so
810
+ * a sandbox image ahead of the browser can report a rule that has no label or replacement here — and a row
811
+ * saying "42 files use react-foo" with no idea what to do about them is worse than no row.
812
+ *
813
+ * AN IDIOM BELONGING TO A FRAMEWORK THIS REPOSITORY DOES NOT USE. A probe's command is a fixed string, so
814
+ * every rule in the table is swept in every repository, and an Angular pattern gets its chance in a Vue
815
+ * codebase: `RouterModule.forRoot` inside a comment, a `*ngIf` in an example string, and — the case that
816
+ * caught this — the book's own rule table quoting its own patterns back at it. What the repository
817
+ * DECLARES is the arbiter, the same `deps` the gate above reads. */
818
+ const frameworks = new Set(frameworksOf(context.signals.shape.deps).map((framework) => framework.id));
819
+ const found = facts.scan.idioms.flatMap(({ id, files }) => {
820
+ const rule = idiomRule(id);
821
+ return rule === undefined || !frameworks.has(rule.framework) || files.length === 0 ? [] : [{ rule, files }];
822
+ });
823
+ if (found.length === 0) {
824
+ return undefined;
825
+ }
826
+ const total = found.reduce((sum, entry) => sum + entry.files.length, 0);
827
+ const ranked = found.toSorted((left, right) => right.files.length - left.files.length);
828
+ return {
829
+ headline: `${plural(found.length, `retired idiom`)} still in use, across ${plural(total, `file`)}`,
830
+ detail: ranked.map((entry) => `${plural(entry.files.length, `file`)} · ${entry.rule.label} → ${entry.rule.replacement}`),
831
+ digest: digestOf(...ranked.map((entry) => `${entry.rule.id}:${bucketOf(entry.files.length)}`).toSorted()),
832
+ severity: `info`,
833
+ why:
834
+ `${repoLabel(context.repo)} still uses ${plural(found.length, `idiom`)} its framework has replaced: ` +
835
+ `${ranked.map((entry) => `${entry.rule.label} in ${plural(entry.files.length, `file`)} (replaced by ${entry.rule.replacement})`).join(`; `)}. ` +
836
+ `A sample of the files: ${ranked
837
+ .flatMap((entry) => entry.files.slice(0, 3))
838
+ .slice(0, DETAIL_LIMIT)
839
+ .join(`, `)}.`,
840
+ };
841
+ },
842
+ diagnosis: `A retired idiom keeps working until the major release that drops it, and then it is an emergency inside somebody else's upgrade.`,
843
+ goal:
844
+ `Take ONE idiom, the one with the most files, and no more. Convert the files where the conversion is mechanical and the behaviour ` +
845
+ `is provably identical. Stop at the first file that needs a design decision — a class component with genuine error-boundary ` +
846
+ `semantics, an NgModule that something outside the repository imports — leave it, and say what it would take. Do not convert an ` +
847
+ `idiom the repository has deliberately kept: if the newest code uses it too, that is a choice, and reporting it as one is the ` +
848
+ `useful answer.`,
849
+ done: `Done when a re-scan reports fewer files on that idiom, the repository's type-check and tests pass, and every file you skipped has a one-line reason.`,
850
+ };
851
+
852
+ /* COMPONENTS. Two components that are the same component, which is the `library-overlap` finding turned inward:
853
+ * somebody needed a button, did not find the one that existed, and wrote a second one. It is the most ordinary
854
+ * kind of duplication in a front-end and the one no tool complains about, because both files are perfectly good
855
+ * code and neither knows the other exists.
856
+ *
857
+ * TWO KINDS OF EVIDENCE, and they catch opposite failures. A NAME FAMILY catches components that were written
858
+ * separately and never shared a line — `BaseButton.vue` and `ButtonV2.tsx` reduce to the same stem, and no clone
859
+ * detector will ever connect them. A CLONE PAIR catches the reverse: two components with unrelated names doing
860
+ * the same work, which is what jscpd is actually good at, filtered to the pairs where both sides are components
861
+ * so it is a finding about the UI rather than a slice of the repo-wide duplication chore.
862
+ *
863
+ * It needs jscpd rather than reading it if present. Half a measurement would let the row claim it had looked for
864
+ * shared logic in a repository where that sweep has never run — the exact "measured and found nothing" lie the
865
+ * `unavailable` state exists to make impossible. jscpd is already running weekly for the duplication chore in any
866
+ * Node repository, so the honest choice is also the free one. */
867
+ const componentOverlap: Chore = {
868
+ id: `component-overlap`,
869
+ title: `Settle on one component per job`,
870
+ icon: `copy`,
871
+ description: `Components built twice — the same name in two places, or the same logic under two names.`,
872
+ kind: `drifting`,
873
+ criterion: `Two component files reduce to the same name, or a duplicated block spans two components.`,
874
+ applies: needsFramework,
875
+ stance: `report`,
876
+ needs: [`ui`, `jscpd`],
877
+ cadenceMs: 90 * DAY_MS,
878
+ assess: (context) => {
879
+ const ui = factsOf(context, `ui`);
880
+ const jscpd = factsOf(context, `jscpd`);
881
+ if (ui === undefined || jscpd === undefined) {
882
+ return undefined;
883
+ }
884
+ const byStem = new Map<string, string[]>();
885
+ for (const path of ui.scan.components) {
886
+ const stem = componentStem(path);
887
+ if (stem !== undefined) {
888
+ byStem.set(stem, [...(byStem.get(stem) ?? []), normalizePath(path)]);
889
+ }
890
+ }
891
+ const families = [...byStem]
892
+ .filter(([, paths]) => paths.length > 1)
893
+ .map(([stem, paths]) => ({ stem, paths: paths.toSorted() }))
894
+ .toSorted((left, right) => right.paths.length - left.paths.length);
895
+ // Only the clones with a component on BOTH sides. A component that shares a block with a utility module
896
+ // is the duplication chore's finding, not this one, and reporting it here would be two rows lighting for
897
+ // one fact.
898
+ const inventory = new Set(ui.scan.components.map(normalizePath));
899
+ const pairs = jscpd.duplication.top.filter(
900
+ (clone) => inventory.has(normalizePath(clone.first)) && inventory.has(normalizePath(clone.second)),
901
+ );
902
+ if (families.length === 0 && pairs.length === 0) {
903
+ return undefined;
904
+ }
905
+ const parts = [
906
+ ...(families.length === 0 ? [] : [`${plural(families.length, `name`)} used by more than one component`]),
907
+ ...(pairs.length === 0 ? [] : [`${plural(pairs.length, `clone`)} spanning two of them`]),
908
+ ];
909
+ return {
910
+ headline: parts.join(`, `),
911
+ detail: [
912
+ ...families.slice(0, DETAIL_LIMIT).map((family) => `${family.stem} · ${family.paths.join(`, `)}`),
913
+ ...pairs.map((clone) => `${clone.lines} shared lines · ${normalizePath(clone.first)} ↔ ${normalizePath(clone.second)}`),
914
+ ],
915
+ // Identities on both halves: every component that joins or leaves a family, and every clone pair that
916
+ // appears, is genuinely a new fact rather than drift in a number.
917
+ digest: digestOf(
918
+ ...families.map((family) => `${family.stem}:${family.paths.join(`+`)}`).toSorted(),
919
+ ...pairs.map((clone) => `${normalizePath(clone.first)}|${normalizePath(clone.second)}`).toSorted(),
920
+ ),
921
+ severity: `info`,
922
+ why:
923
+ `${repoLabel(context.repo)} has ${parts.join(` and `)}, out of ${plural(ui.scan.components.length, `component file`)} scanned. ` +
924
+ `${
925
+ families.length === 0
926
+ ? ``
927
+ : `The names: ${families
928
+ .slice(0, DETAIL_LIMIT)
929
+ .map((family) => `${family.stem} (${family.paths.join(`, `)})`)
930
+ .join(`; `)}. `
931
+ }` +
932
+ `${pairs.length === 0 ? `` : `The clones: ${pairs.map((clone) => `${normalizePath(clone.first)} ↔ ${normalizePath(clone.second)}, ${clone.lines} lines`).join(`; `)}.`}`,
933
+ };
934
+ },
935
+ diagnosis: `A component built twice is maintained once — whichever copy the next person happens to open is the one that gets the fix.`,
936
+ goal:
937
+ `Read every file in each group before saying anything about it; a shared name is a reason to look, not a finding on its own. For ` +
938
+ `each group, say whether these genuinely do the same job, and if they do, name the one to keep and count the call sites that would ` +
939
+ `have to move. Where the answer is that the same LOGIC is duplicated rather than the whole component — the same fetch and loading ` +
940
+ `state, the same form validation, the same list virtualization written twice — say so, and name the hook or composable it should ` +
941
+ `become and where it would live. Where two components share a name and nothing else, say that too and close it: a false family is ` +
942
+ `worth one line, and the next reader needs to know it was considered.`,
943
+ done: `Done when every group has either a component to keep with a call-site count, a shared unit to extract with a home, or a reason it is fine.`,
944
+ };
945
+
946
+ /* TAILWIND. A design system exists to make a decision once; an arbitrary value is that decision being made again,
947
+ * inline, by whoever was in the file. What makes this measurable rather than a matter of taste is that Tailwind
948
+ * spells the bypass out loud — `bg-[#3b82f6]` is the palette being stepped around, in the markup, in a form no
949
+ * reviewer can miss and no linter mentions.
950
+ *
951
+ * Deliberately NOT every arbitrary value. `grid-cols-[1fr_auto]` is the feature working as intended and there is
952
+ * no token it should have been; matching those would make this an objection to Tailwind rather than a finding
953
+ * about this repository. Only colours and pixel sizes, which are the two things the theme definitely already has
954
+ * an answer for. */
955
+ const tailwindBypass: Chore = {
956
+ id: `tailwind-arbitrary-values`,
957
+ title: `Put hard-coded styles back on the scale`,
958
+ icon: `palette`,
959
+ description: `Colours and sizes written inline in the markup, around the theme that already defines them.`,
960
+ kind: `drifting`,
961
+ criterion: `A Tailwind class hard-codes a colour or a pixel size instead of using the theme's scale.`,
962
+ applies: (signals) => (usesTailwind(signals.shape.deps) ? undefined : `no Tailwind`),
963
+ stance: `act`,
964
+ needs: [`ui`],
965
+ cadenceMs: 30 * DAY_MS,
966
+ assess: (context) => {
967
+ const facts = factsOf(context, `ui`);
968
+ if (facts === undefined) {
969
+ return undefined;
970
+ }
971
+ const { bypasses } = facts.scan;
972
+ if (bypasses.length === 0) {
973
+ return undefined;
974
+ }
975
+ const total = bypasses.reduce((sum, entry) => sum + entry.count, 0);
976
+ const worst = bypasses.toSorted((left, right) => right.count - left.count).slice(0, DETAIL_LIMIT);
977
+ return {
978
+ headline: `${plural(total, `hard-coded value`)} across ${plural(bypasses.length, `file`)}`,
979
+ detail: worst.map((entry) => `${entry.path} · ${plural(entry.count, `value`)}`),
980
+ // The worst files by identity — a new file arriving at the top of this list is the event — with the
981
+ // spread and the total riding along bucketed, because both drift by one every time anyone writes
982
+ // markup and neither is worth interrupting somebody about.
983
+ digest: digestOf(...worst.map((entry) => entry.path).toSorted(), `files:${bucketOf(bypasses.length)}`, `total:${bucketOf(total)}`),
984
+ severity: `info`,
985
+ why:
986
+ `${repoLabel(context.repo)} has ${plural(total, `Tailwind class`, `Tailwind classes`)} hard-coding a colour or a pixel size ` +
987
+ `across ${plural(bypasses.length, `file`)}; the heaviest are ` +
988
+ `${worst
989
+ .slice(0, 5)
990
+ .map((entry) => `${entry.path} (${entry.count})`)
991
+ .join(`, `)}.`,
992
+ };
993
+ },
994
+ diagnosis: `Every inline colour is a place the theme cannot reach — a palette change lands everywhere except the files that opted out of it.`,
995
+ goal:
996
+ `Read the theme first — the Tailwind config, or the CSS that defines the tokens — so you know what the scale actually offers. Then ` +
997
+ `replace the values that have a token: an exact palette match, a spacing step, a type size. Where a value is CLOSE to a token but ` +
998
+ `not equal, do not round it silently; that is a visual change wearing a refactor's clothes. List those separately with both values ` +
999
+ `and let the owner decide. Where a value has no token and should — a brand colour used in nine places — say that the theme is ` +
1000
+ `missing an entry rather than editing nine files.`,
1001
+ done: `Done when a re-scan reports fewer hard-coded values, nothing renders differently, and every value you left has a one-line reason.`,
1002
+ };
1003
+
621
1004
  /* THE SURVEYS. Chores with no measurement at all, and they are here because the absence of a measurement is not
622
1005
  * the absence of value — these are the reviews a codebase silently rots without, and none of them can be detected
623
1006
  * by a tool. Their trigger is the calendar, and the ledger is what makes that trigger honest: a survey is due
@@ -655,6 +1038,10 @@ const survey = ({ id, title, icon, description, diagnosis, goal, done, cadenceDa
655
1038
  title,
656
1039
  icon,
657
1040
  description,
1041
+ // Not a parameter of SurveySpec, and it never will be: a survey has no measurement, so "due because it has
1042
+ // been that long" IS the surveying kind. The two are the same claim spelled twice, and the test below holds
1043
+ // them to it in both directions.
1044
+ kind: `surveying`,
658
1045
  criterion: `${cadenceDays} days have passed since this review was last run.`,
659
1046
  applies,
660
1047
  stance: `report`,
@@ -693,10 +1080,9 @@ const patterns = survey({
693
1080
  `estimate the size of the conversion. Do not convert anything.`,
694
1081
  done: `Done when each concern has a named convention, a reference file, and a count of the sites that diverge from it.`,
695
1082
  cadenceDays: 90,
696
- applies: (signals) =>
697
- signals.totals.files >= PATTERNS_FLOOR
698
- ? undefined
699
- : `this repository has ${signals.totals.files} indexed files — too few for cross-cutting patterns to have diverged`,
1083
+ // The one cause that is a measurement rather than an absence, and it still groups: every chore gated on size
1084
+ // is gated on the SAME size, so the string is the same string.
1085
+ applies: (signals) => (signals.totals.files >= PATTERNS_FLOOR ? undefined : `only ${signals.totals.files} indexed files`),
700
1086
  });
701
1087
 
702
1088
  const deprecated = survey({
@@ -712,7 +1098,7 @@ const deprecated = survey({
712
1098
  `replacement for each. Change nothing.`,
713
1099
  done: `Done when every deprecation has call sites cited, a replacement named, and the release it is expected to break in.`,
714
1100
  cadenceDays: 90,
715
- applies: (signals) => (signals.shape.packageManifest ? undefined : `this repository declares no dependencies whose deprecations could be read`),
1101
+ applies: (signals) => (signals.shape.packageManifest ? undefined : `no package.json`),
716
1102
  });
717
1103
 
718
1104
  /* THE CHORE THAT NAMED THE PROBLEM. Gated on documents actually EXISTING, which is the whole reason `applies`
@@ -720,8 +1106,10 @@ const deprecated = survey({
720
1106
  * re-read, and the first thing an owner of a fresh workspace sees is an offer to re-read documentation they have
721
1107
  * never written. That is not a chore being wrong about a threshold — it is the surface admitting it never looked.
722
1108
  *
723
- * Note which fact it gates on: the DOCUMENTS, not the directory. An empty `docs/architecture/` is a directory
724
- * somebody made and never filled, and a gate on the directory would put the chore back exactly where it started. */
1109
+ * Note which fact it gates on: the MAP, not the directory. An empty `docs/architecture/` is a directory somebody
1110
+ * made and never filled, and a gate on the directory would put the chore back exactly where it started. The
1111
+ * survey then reads the package READMEs too — they are the package pages — but a repo with no map has not been
1112
+ * documented at all, and that is the case worth staying quiet for. */
725
1113
  const documentationDrift = survey({
726
1114
  id: `documentation-drift`,
727
1115
  title: `Re-read the documentation against the code`,
@@ -734,7 +1122,7 @@ const documentationDrift = survey({
734
1122
  `what, which file to change — over prose that has merely aged. Do not rewrite the documents; produce the list of what is wrong.`,
735
1123
  done: `Done when every architecture document has been read and every false claim is listed with both sides cited.`,
736
1124
  cadenceDays: 90,
737
- applies: (signals) => (signals.shape.docs.length > 0 ? undefined : `this repository has no architecture documents to re-read`),
1125
+ applies: (signals) => (signals.shape.docs.length > 0 ? undefined : `no architecture documents`),
738
1126
  });
739
1127
 
740
1128
  /* THE TWO CHORES THAT ONLY EXIST WHERE THEIR SUBJECT DOES. Both are surveys — nothing here can measure whether a
@@ -756,7 +1144,7 @@ const pipelines = survey({
756
1144
  `because it genuinely has to be, say so — a pipeline that is honestly expensive is not a finding.`,
757
1145
  done: `Done when every finding names a file, a step, and a concrete change, and anything deliberately slow is called out as such.`,
758
1146
  cadenceDays: 90,
759
- applies: (signals) => (signals.shape.ci.length > 0 ? undefined : `this repository defines no CI pipeline`),
1147
+ applies: (signals) => (signals.shape.ci.length > 0 ? undefined : `no CI pipeline`),
760
1148
  });
761
1149
 
762
1150
  const images = survey({
@@ -772,28 +1160,58 @@ const images = survey({
772
1160
  `Dockerfiles — an image that fails to build is a much worse problem than one that is larger than ideal.`,
773
1161
  done: `Done when every finding cites a Dockerfile line and names the change, with the ones that would need a base-image swap called out separately.`,
774
1162
  cadenceDays: 90,
775
- applies: (signals) => (signals.shape.dockerfiles.length > 0 ? undefined : `this repository ships no Dockerfile`),
1163
+ applies: (signals) => (signals.shape.dockerfiles.length > 0 ? undefined : `no Dockerfile`),
776
1164
  });
777
1165
 
778
1166
  /* THE BOOK'S ORDER, which is the panel's reading order and therefore a product decision rather than whatever
779
1167
  * order these were written in. It narrows from "this is a risk you are carrying right now" to "this is worth
780
- * thinking about this quarter":
781
- * carrying security, runtime — someone else decides when these become urgent
782
- * accruing dependencies, dead code, complexitycheap now, expensive later, and always getting later
783
- * drifting documentation, duplication, libraries the shape of the thing is diverging from the idea of it
784
- * surveying the periodic reads, which have no urgency by construction
1168
+ * thinking about this quarter".
1169
+ *
1170
+ * This used to be a comment above a hand-sorted array the four kinds named in prose, the order maintained by
1171
+ * whoever added the last chore, and nothing anywhere that could check the two agreed. It was also thrown away at
1172
+ * render: the panel listed every chore in one flat column, so the single editorial claim this surface makes
1173
+ * ("a live advisory and a quarterly re-read are not the same kind of thing") was invisible and therefore
1174
+ * unarguable — on a page whose whole design is that every claim shows its working.
1175
+ *
1176
+ * So the kinds are data. They order the book here, they group the rows in the panel, and `caption` is the
1177
+ * sentence the panel puts beside each group so the grouping argues for itself.
785
1178
  *
786
1179
  * Ordering is by KIND, not by whether a given repository will see them: a chore that does not apply is dropped
787
- * from that repository's list entirely (verdict.ts), so the reading order never has holes in it. */
788
- export const CHORES: readonly Chore[] = [
1180
+ * from that repository's list entirely (verdict.ts), so the reading order never has holes in it. It is also why
1181
+ * a block of chores written together does not READ together: the front-end four are one paragraph in this file
1182
+ * because they share a gate and a probe, and `kind` is what puts a Vue repository's bundle row next to its
1183
+ * dependency row rather than in a "front-end" section at the bottom. Where a chore is written and where it is
1184
+ * ranked are two separate facts, and only one of them is a product decision. */
1185
+ export interface ChoreKindSpec {
1186
+ readonly kind: ChoreKind;
1187
+ // Title case, because the panel renders it as a group heading rather than as a sentence.
1188
+ readonly label: string;
1189
+ // Why these belong together, in the reader's terms — what the group is CLAIMING about the rows under it.
1190
+ readonly caption: string;
1191
+ }
1192
+
1193
+ export const CHORE_KINDS: readonly ChoreKindSpec[] = [
1194
+ { kind: `carrying`, label: `Carrying`, caption: `a risk this repository is running today — someone else decides when it becomes urgent` },
1195
+ { kind: `accruing`, label: `Accruing`, caption: `cheap now, expensive later, and always getting later` },
1196
+ { kind: `drifting`, label: `Drifting`, caption: `the shape of the thing is diverging from the idea of it` },
1197
+ { kind: `surveying`, label: `Surveying`, caption: `periodic reads with nothing measuring them — due because it has been that long` },
1198
+ ];
1199
+
1200
+ // Declaration order, which decides nothing but the order WITHIN a kind — the sort below is stable, so the two
1201
+ // facts stay separable: this list is where a chore is written down, CHORE_KINDS is where it is ranked.
1202
+ const BOOK: readonly Chore[] = [
789
1203
  security,
790
1204
  runtime,
791
1205
  dependencies,
792
1206
  deadCode,
793
1207
  complexity,
1208
+ bundleWeight,
1209
+ frameworkIdiom,
794
1210
  documentation,
795
1211
  duplication,
796
1212
  libraries,
1213
+ componentOverlap,
1214
+ tailwindBypass,
797
1215
  patterns,
798
1216
  deprecated,
799
1217
  documentationDrift,
@@ -801,6 +1219,12 @@ export const CHORES: readonly Chore[] = [
801
1219
  images,
802
1220
  ];
803
1221
 
1222
+ const KIND_ORDER: readonly ChoreKind[] = CHORE_KINDS.map(({ kind }) => kind);
1223
+
1224
+ // Sorted rather than filtered into groups, so no chore can ever be dropped out of the book by a kind the list
1225
+ // above forgot — a missing kind sorts to the front, where it is visible, instead of vanishing.
1226
+ export const CHORES: readonly Chore[] = BOOK.toSorted((left, right) => KIND_ORDER.indexOf(left.kind) - KIND_ORDER.indexOf(right.kind));
1227
+
804
1228
  export const choreById = (id: string): Chore | undefined => CHORES.find((chore) => chore.id === id);
805
1229
 
806
1230
  // The prompt for one chore against one finding. Built here rather than in the view because the panel, the badge's