@intentic/sandbox-contract 1.176.3 → 1.209.1

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 +40 -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 +205 -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 +123 -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 +3792 -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 +4889 -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 +61 -0
  276. package/src/conversation-ids.ts +173 -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 +3480 -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
Binary file
@@ -0,0 +1,37 @@
1
+ import { composeAsk } from "./prompt.js";
2
+
3
+ /* AN EXTENSION UPDATE, READ AS A DIFF. The commit that is installed was approved once already — re-reading all
4
+ * of it would bury the one question an update asks: what is different, and did any of it change the deal? So
5
+ * the turn's subject is the diff between the two commits, and the manifest's delta leads, because a new entry
6
+ * in `permissions.sandbox` is reach the owner never approved, arriving dressed as an update.
7
+ *
8
+ * Here in the contract's chores rather than in the web app, because two callers build it: the update card's
9
+ * "read the diff" button, and the daemon's agent-prepared update policy — which runs this exact read
10
+ * unprompted when the registry lists a new sha, so the owner opens a finished account instead of starting one. */
11
+ const UPDATE_INVARIANTS =
12
+ `This turn reads and reports; it changes nothing and installs nothing. Clone into a scratch directory ` +
13
+ `outside the workspace and read the diff between the two commits — the installed code was approved once ` +
14
+ `already, so what is between them is the whole subject. Lead with the manifest's delta: any route added to ` +
15
+ `\`permissions.sandbox\` is reach the owner never approved and the headline whatever else changed. Then the ` +
16
+ `code: what behaviour changed, in the owner's terms, citing file and line.`;
17
+
18
+ export interface UpdateBrief {
19
+ // The listing's display name, or the repository when it is being installed straight from a URL.
20
+ readonly label: string;
21
+ readonly url: string;
22
+ // What is installed and what the update proposes — both full shas, both facts, neither a branch.
23
+ readonly fromRef: string;
24
+ readonly toRef: string;
25
+ // Subdirectory inside the repository, for a monorepo source. Empty for a repo of its own.
26
+ readonly path: string;
27
+ }
28
+
29
+ export const updateBrief = ({ label, url, fromRef, toRef, path }: UpdateBrief): string =>
30
+ composeAsk({
31
+ subject: `Read what changed in the ${label} extension before it is updated here: ${url}, from ${fromRef} to ${toRef}${path === `` ? `` : `, in ${path}`}.`,
32
+ why: `The installed commit was approved once already; the update replaces it wholesale, because the sha is the identity and there is no build step between the pushed bytes and the code that runs.`,
33
+ diagnosis: `The manifest (intentic-extension.json at the extension root) is the contract on both sides of the diff, so its delta is readable exactly like the code's.`,
34
+ goal: `Read the diff and say what the update actually is: the manifest delta first, then what the code now does that it did not, and what it stopped doing.`,
35
+ invariants: UPDATE_INVARIANTS,
36
+ done: `Done when you end on a recommendation the owner can act on — update, update and watch something named, or stay on ${fromRef.slice(0, 7)} — with the change that decided it cited by file and line.`,
37
+ });
@@ -0,0 +1,40 @@
1
+ /* THE FIX CHORE — one definition for the two surfaces that must agree on it.
2
+ *
3
+ * The daemon seeds this automation into a workspace (default-automations.ts in the sandbox package) and the
4
+ * automations extension offers it as a recipe for anyone who deleted it and wants it back. Written once here
5
+ * for the chore book's own reason: two copies of a prompt drift, and only one of them gets fixed when we
6
+ * learn how to phrase it.
7
+ *
8
+ * It wakes on `deps.broken` — the dependency verifier's edge event: a landed change drifted the installed
9
+ * dependencies, the daemon reinstalled them, ran the tree's own checks, and they came back red. The payload
10
+ * is that event: `deps.project` is the project whose checks failed, `deps.command` the exact command that
11
+ * judged it, `deps.exitCode` and `deps.logTail` what it said, and `deps.attempt` which consecutive red this
12
+ * is since the last green.
13
+ *
14
+ * THE GUARD IS THE LOOP CAP, and it lives in guard shell — one visible, owner-editable line — rather than in
15
+ * daemon code, because whoever tunes "how many tries before a human looks" must be able to see the number.
16
+ * Attempt 1 is the breakage, attempt 2 is one landed fix that still failed; past that the loop stops and the
17
+ * standing red is the owner's to read (the activity feed has been narrating every step).
18
+ *
19
+ * THE HOLD IS THE CONSENT. This automation is seeded enabled — the one exception to "the owner started it",
20
+ * made deliberately: a broken tree costs every conversation that builds on it, and the countdown is what
21
+ * keeps the owner in charge — each fire is held, visibly, for this many seconds on the Automations page
22
+ * before it starts, cancellable the whole way, and it never starts while another agent is mid-turn. */
23
+ export const FIX_DEPS_AUTOMATION = {
24
+ id: "fix-dependency-breakage",
25
+ title: "Fix what a dependency change broke",
26
+ event: "deps.broken",
27
+ holdForSeconds: 60,
28
+ guard: `test "$(printf '%s' "$AUTOMATION_PAYLOAD" | jq -r '.deps.attempt // 1')" -le 2`,
29
+ guardNote: "stops after 2 attempts",
30
+ prompt:
31
+ "A landed change drifted this workspace's dependencies; the daemon reinstalled them and ran the project's own checks, " +
32
+ "and they failed. The payload names the project (`deps.project`), the check command (`deps.command`), how it exited " +
33
+ "(`deps.exitCode`) and the tail of its output (`deps.logTail`); the full log is in the project's `--verify` terminal.\n\n" +
34
+ "Re-run the check yourself to see the failure first-hand, then fix the ROOT CAUSE. That usually means updating call " +
35
+ "sites, types or tests to match what actually changed — never loosening or deleting the checks, pinning or downgrading " +
36
+ "dependencies just to silence them, or editing generated files. If the breakage needs a decision only the owner can " +
37
+ "make (an intentional breaking upgrade, a license change, a dependency that should be dropped), stop and say exactly " +
38
+ "that instead of guessing.\n\n" +
39
+ "Finish by running the same check command and reporting what was broken, what you changed, and whether it is green now.",
40
+ } as const;
@@ -1,9 +1,14 @@
1
- export { CHORES, choreAutomationPrompt, choreById, chorePrompt, repoLabel } from "./chores.js";
2
- export type { Chore, ChoreContext, ChoreFinding, ChoreStance } from "./chores.js";
1
+ export { CHORE_KINDS, CHORES, choreAutomationPrompt, choreById, chorePrompt, repoLabel, repoName } from "./chores.js";
2
+ export { FIX_DEPS_AUTOMATION } from "./fix-deps.js";
3
+ export type { Chore, ChoreContext, ChoreFinding, ChoreKind, ChoreKindSpec, ChoreStance } from "./chores.js";
3
4
  export { bucketOf, digestOf } from "./digest.js";
4
5
  export { CHORE_INVARIANTS, composeAsk, REFACTOR_INVARIANTS, REPORT_INVARIANTS, TRIAGE_NOTE } from "./prompt.js";
5
6
  export type { Ask } from "./prompt.js";
7
+ export { updateBrief } from "./extension-update.js";
8
+ export type { UpdateBrief } from "./extension-update.js";
6
9
  export { PROBES, probeSpec } from "./probes.js";
7
10
  export type { ProbeSpec } from "./probes.js";
11
+ export { componentStem, frameworksOf, IDIOM_RULES, idiomRule, normalizePath, UI_FRAMEWORKS, usesTailwind } from "./stack.js";
12
+ export type { IdiomRule, UiFramework } from "./stack.js";
8
13
  export { assessChore, assessReport, ledgerKey, unseenVerdicts } from "./verdict.js";
9
14
  export type { ChoreState, ChoreVerdict } from "./verdict.js";
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, test } from "vitest";
2
2
  import { probeSpec } from "./probes.js";
3
+ import { IDIOM_RULES } from "./stack.js";
3
4
 
4
5
  /* The parsers are the part of this library that faces someone else's output, so they are tested the way that
5
6
  * output actually arrives: real shapes, then the shapes that have historically broken things — a tool that
@@ -30,8 +31,14 @@ describe(`outdated`, () => {
30
31
  });
31
32
 
32
33
  test(`skips entries pnpm could not resolve, rather than inventing a version for them`, () => {
33
- const facts = parse(`outdated`, JSON.stringify({ ok: { current: `1.0.0`, latest: `2.0.0` }, broken: { current: `1.0.0` }, alsoBroken: null }));
34
- expect(facts).toEqual({ id: `outdated`, packages: [{ name: `ok`, current: `1.0.0`, latest: `2.0.0`, kind: `major`, section: `dependencies` }] });
34
+ const facts = parse(
35
+ `outdated`,
36
+ JSON.stringify({ ok: { current: `1.0.0`, latest: `2.0.0` }, broken: { current: `1.0.0` }, alsoBroken: null }),
37
+ );
38
+ expect(facts).toEqual({
39
+ id: `outdated`,
40
+ packages: [{ name: `ok`, current: `1.0.0`, latest: `2.0.0`, kind: `major`, section: `dependencies` }],
41
+ });
35
42
  });
36
43
 
37
44
  // pnpm prints deprecation and lockfile notices on the same stream in some versions; the JSON still has to be
@@ -94,7 +101,14 @@ describe(`knip`, () => {
94
101
  `knip`,
95
102
  JSON.stringify({
96
103
  issues: [
97
- { file: `src/a.ts`, exports: [{ name: `x` }, { name: `y` }], types: [{ name: `T` }], dependencies: [{ name: `lodash` }], devDependencies: [], files: [] },
104
+ {
105
+ file: `src/a.ts`,
106
+ exports: [{ name: `x` }, { name: `y` }],
107
+ types: [{ name: `T` }],
108
+ dependencies: [{ name: `lodash` }],
109
+ devDependencies: [],
110
+ files: [],
111
+ },
98
112
  { file: `src/b.ts`, exports: [{ name: `z` }], types: [], dependencies: [], devDependencies: [{ name: `jest` }], files: [] },
99
113
  { file: `src/old.ts`, exports: [], files: [{ name: `src/old.ts` }] },
100
114
  { file: `src/older.ts`, exports: [], files: [{ name: `src/older.ts` }] },
@@ -164,3 +178,141 @@ describe(`jscpd`, () => {
164
178
  expect(parse(`jscpd`, ``)).toBeUndefined();
165
179
  });
166
180
  });
181
+
182
+ /* The UI sweep is the one probe whose output we produce ourselves, which removes the "their JSON moved" failure
183
+ * and replaces it with a worse one: a command of eleven piped ripgreps in which any single stage can silently
184
+ * contribute nothing. The marker line is what tells those two apart, and most of what is below is about it. */
185
+ describe(`ui`, () => {
186
+ const sweep = (...lines: readonly string[]) => [`UI`, ...lines].join(`\n`);
187
+
188
+ test(`sorts the labelled lines into an inventory, per-file counts and idioms`, () => {
189
+ const facts = parse(
190
+ `ui`,
191
+ sweep(
192
+ `COMPONENT\tsrc/Button.vue`,
193
+ `COMPONENT\tsrc/Card.tsx`,
194
+ `BYPASS\tsrc/Button.vue:3`,
195
+ `IDIOM\tvue-options-api\tsrc/Button.vue`,
196
+ `IDIOM\tvue-2-lifecycle\tsrc/Button.vue`,
197
+ `IDIOM\tvue-options-api\tsrc/Old.vue`,
198
+ ),
199
+ );
200
+ expect(facts).toEqual({
201
+ id: `ui`,
202
+ scan: {
203
+ components: [`src/Button.vue`, `src/Card.tsx`],
204
+ bypasses: [{ path: `src/Button.vue`, count: 3 }],
205
+ idioms: [
206
+ { id: `vue-options-api`, files: [`src/Button.vue`, `src/Old.vue`] },
207
+ { id: `vue-2-lifecycle`, files: [`src/Button.vue`] },
208
+ ],
209
+ },
210
+ });
211
+ });
212
+
213
+ /* The distinction the marker exists for, and the one this whole probe would get wrong without it: a
214
+ * repository with no components and no findings emits exactly the marker, while a sweep that never ran emits
215
+ * nothing. Collapsing them would report a spotless front-end for a command that failed to start. */
216
+ test(`the marker alone is a clean repository`, () => {
217
+ expect(parse(`ui`, sweep())).toEqual({ id: `ui`, scan: { components: [], bypasses: [], idioms: [] } });
218
+ });
219
+
220
+ test(`output with no marker is a failure, however much of it there is`, () => {
221
+ expect(parse(`ui`, ``)).toBeUndefined();
222
+ expect(parse(`ui`, `COMPONENT\tsrc/Button.vue`)).toBeUndefined();
223
+ expect(parse(`ui`, `rg: unrecognized flag --count-matches`)).toBeUndefined();
224
+ });
225
+
226
+ // A path with a colon in it is legal and rare; the count is always the digits after the last one.
227
+ test(`splits a count off the end of a path that contains a colon`, () => {
228
+ expect(parse(`ui`, sweep(`BYPASS\tsrc/weird:name.vue:7`))).toMatchObject({ scan: { bypasses: [{ path: `src/weird:name.vue`, count: 7 }] } });
229
+ });
230
+
231
+ test(`a line that is not a count is dropped rather than counted as zero`, () => {
232
+ expect(parse(`ui`, sweep(`BYPASS\tsrc/Button.vue`, `BYPASS\tsrc/Card.tsx:notanumber`))).toMatchObject({ scan: { bypasses: [] } });
233
+ });
234
+
235
+ /* Every path the sweep prints wears a `./`, because every ripgrep in it is handed `.` to walk. Downstream this
236
+ * would have to be remembered at each comparison — jscpd's paths against the component list, a bypass against
237
+ * a component — so it is spent once, here, and one spelling of a path leaves the parser. */
238
+ test(`strips the prefix ripgrep prints for a path it was told to walk`, () => {
239
+ expect(parse(`ui`, sweep(`COMPONENT\t./src/Button.vue`, `BYPASS\t./src/Button.vue:3`, `IDIOM\tvue-options-api\t./src/Old.vue`))).toEqual({
240
+ id: `ui`,
241
+ scan: {
242
+ components: [`src/Button.vue`],
243
+ bypasses: [{ path: `src/Button.vue`, count: 3 }],
244
+ idioms: [{ id: `vue-options-api`, files: [`src/Old.vue`] }],
245
+ },
246
+ });
247
+ });
248
+
249
+ test(`an idiom line naming no file is dropped rather than recorded as an empty path`, () => {
250
+ expect(parse(`ui`, sweep(`IDIOM\tvue-options-api`, `IDIOM\tvue-options-api\t`))).toMatchObject({ scan: { idioms: [] } });
251
+ });
252
+ });
253
+
254
+ /* THE COMMAND ITSELF, which for this probe is generated and therefore the thing to test. Both cases below are
255
+ * bugs that reached a real repository and could not be seen in the output: one made the sweep silently empty, the
256
+ * other made it depend on a ripgrep feature that is a compile-time option. */
257
+ describe(`the sweep's composed command`, () => {
258
+ const stages = (): string[] => probeSpec(`ui`).command.split(`; `);
259
+
260
+ /* Given no path, ripgrep searches STDIN whenever stdin is not a TTY — which is exactly how a probe is spawned.
261
+ * The sweep exited 0, printed its marker and matched nothing, in every repository, forever, which the marker
262
+ * line cannot catch because the sweep really did run. It reproduces from a child process and never from an
263
+ * interactive shell, so the command is the only place it is visible. */
264
+ test(`every ripgrep is given a path to walk, including the availability gate`, () => {
265
+ const searches = stages().filter((stage) => stage.startsWith(`rg `));
266
+ expect(searches).toHaveLength(IDIOM_RULES.length + 2);
267
+ for (const search of searches) {
268
+ expect(search.split(`2>/dev/null`)[0], search).toMatch(/ \.\s*$/);
269
+ }
270
+ expect(probeSpec(`ui`).available).toMatch(/ \. >\/dev\/null$/);
271
+ });
272
+
273
+ test(`an absent rule asks which files do NOT match, and no rule reaches for PCRE2`, () => {
274
+ for (const rule of IDIOM_RULES) {
275
+ const stage = stages().find((part) => part.includes(`"IDIOM\\t${rule.id}\\t"`));
276
+ expect(stage, rule.id).toContain(rule.absent === undefined ? `rg --no-messages -l ` : `rg --no-messages --files-without-match `);
277
+ }
278
+ expect(probeSpec(`ui`).command).not.toContain(`-P `);
279
+ });
280
+ });
281
+
282
+ describe(`bundle`, () => {
283
+ test(`reads the directory and each asset's raw and gzipped size`, () => {
284
+ const facts = parse(
285
+ `bundle`,
286
+ [`DIR\tdist`, `ASSET\t54038\t41096\tdist/assets/vendor-abc.js`, `ASSET\t2704\t2103\tdist/assets/style.css`].join(`\n`),
287
+ );
288
+ expect(facts).toEqual({
289
+ id: `bundle`,
290
+ bundle: {
291
+ dir: `dist`,
292
+ totalBytes: 56742,
293
+ totalGzip: 43199,
294
+ assets: [
295
+ { path: `dist/assets/vendor-abc.js`, bytes: 54038, gzip: 41096 },
296
+ { path: `dist/assets/style.css`, bytes: 2704, gzip: 2103 },
297
+ ],
298
+ },
299
+ });
300
+ });
301
+
302
+ // The `find` prints nothing for a directory that exists but holds no assets. `available` is supposed to catch
303
+ // that, and this is the second line of defence — a zero-byte bundle would otherwise read as a fact.
304
+ test(`a directory line with no assets is an empty build, not a failure`, () => {
305
+ expect(parse(`bundle`, `DIR\tbuild`)).toMatchObject({ bundle: { dir: `build`, assets: [], totalBytes: 0, totalGzip: 0 } });
306
+ });
307
+
308
+ test(`no directory line is a failure`, () => {
309
+ expect(parse(`bundle`, ``)).toBeUndefined();
310
+ expect(parse(`bundle`, `find: dist: No such file or directory`)).toBeUndefined();
311
+ });
312
+
313
+ test(`an asset whose sizes did not come through is skipped, never counted as zero bytes`, () => {
314
+ expect(parse(`bundle`, [`DIR\tdist`, `ASSET\t\t\tdist/broken.js`, `ASSET\t10\t5\tdist/ok.js`].join(`\n`))).toMatchObject({
315
+ bundle: { assets: [{ path: `dist/ok.js`, bytes: 10, gzip: 5 }] },
316
+ });
317
+ });
318
+ });
@@ -1,4 +1,15 @@
1
- import type { Advisory, DeadCode, Duplication, OutdatedPackage, ProbeFacts, ProbeId } from "../schemas.js";
1
+ import type { Advisory, Bundle, DeadCode, Duplication, OutdatedPackage, ProbeFacts, ProbeId, UiScan } from "../schemas.js";
2
+ import type { IdiomRule } from "./stack.js";
3
+ import {
4
+ BYPASS_PATTERN,
5
+ COMPONENT_GLOBS,
6
+ IDIOM_RULES,
7
+ MARKUP_GLOBS,
8
+ normalizePath,
9
+ SCAN_IGNORES,
10
+ UI_FRAMEWORKS,
11
+ TAILWIND_PACKAGES,
12
+ } from "./stack.js";
2
13
 
3
14
  /* THE PROBES — the measurements that cost a subprocess, declared once so the daemon that runs them and the panel
4
15
  * that explains them cannot disagree about what "outdated" meant.
@@ -68,7 +79,11 @@ const countOf = (value: unknown): number => (Array.isArray(value) ? value.length
68
79
 
69
80
  // Which semver step separates two versions. Compared as leading integers rather than by a semver library: the
70
81
  // only question is which position first differs, and prerelease/build metadata cannot change that answer.
71
- const versionParts = (version: string): number[] => version.replace(/^[^\d]*/, ``).split(`.`).map((part) => Number.parseInt(part, 10) || 0);
82
+ const versionParts = (version: string): number[] =>
83
+ version
84
+ .replace(/^[^\d]*/, ``)
85
+ .split(`.`)
86
+ .map((part) => Number.parseInt(part, 10) || 0);
72
87
 
73
88
  const semverKind = (current: string, latest: string): OutdatedPackage["kind"] => {
74
89
  const [currentMajor = 0, currentMinor = 0] = versionParts(current);
@@ -195,18 +210,162 @@ const parseJscpd = (stdout: string): ProbeFacts | undefined => {
195
210
  const total = (statistics as Record<string, unknown>)[`total`];
196
211
  const percentage = typeof total === `object` && total !== null ? (total as Record<string, unknown>)[`percentage`] : undefined;
197
212
  const duplicates = Array.isArray(root?.[`duplicates`]) ? (root[`duplicates`] as Record<string, unknown>[]) : [];
198
- const pathOf = (side: unknown): string => (typeof side === `object` && side !== null ? (asString((side as Record<string, unknown>)[`name`]) ?? `?`) : `?`);
213
+ const pathOf = (side: unknown): string =>
214
+ typeof side === `object` && side !== null ? (asString((side as Record<string, unknown>)[`name`]) ?? `?`) : `?`;
199
215
  const duplication: Duplication = {
200
216
  percentage: typeof percentage === `number` ? percentage : 0,
201
217
  clones: duplicates.length,
202
218
  top: duplicates
203
- .map((clone) => ({ lines: typeof clone[`lines`] === `number` ? clone[`lines`] : 0, first: pathOf(clone[`firstFile`]), second: pathOf(clone[`secondFile`]) }))
219
+ .map((clone) => ({
220
+ lines: typeof clone[`lines`] === `number` ? clone[`lines`] : 0,
221
+ first: pathOf(clone[`firstFile`]),
222
+ second: pathOf(clone[`secondFile`]),
223
+ }))
204
224
  .toSorted((left, right) => right.lines - left.lines)
205
225
  .slice(0, DUPLICATION_SAMPLE),
206
226
  };
207
227
  return { id: `jscpd`, duplication };
208
228
  };
209
229
 
230
+ /* THE UI SWEEP. The only probe here whose command is COMPOSED rather than written out, because its subject is a
231
+ * table (stack.ts) that will grow and a hand-written command would be a second copy of it going stale.
232
+ *
233
+ * Everything it emits is a labelled, tab-separated line, and the first line is always the bare marker `UI`. That
234
+ * marker is the whole reason this parser can tell "the sweep ran and this repository is clean" from "the sweep
235
+ * never ran": every other line is optional, so without it an empty stdout and a spotless codebase are the same
236
+ * string — and reporting the second when it was the first is the one thing probes.ts exists to prevent. */
237
+ const UI_MARKER = `UI`;
238
+ // Caps, applied after `sort` so truncation is alphabetical and therefore identical between runs — an unsorted
239
+ // truncation would mint a new digest on every sweep and badge forever. What is dropped is genuinely dropped: a
240
+ // component past the cap cannot join a family, and the chore says so rather than implying it saw everything.
241
+ const COMPONENT_LIMIT = 2000;
242
+ const RULE_FILE_LIMIT = 500;
243
+
244
+ /* THE `.` IS LOAD-BEARING, and leaving it off cost this probe every finding it will ever have. Given no path,
245
+ * ripgrep searches the tree only when stdin is a TTY — otherwise it reads STDIN, which is exactly how the runner
246
+ * spawns a probe. The sweep therefore ran, exited 0, printed its marker and matched nothing, in every repository,
247
+ * forever: the precise failure the marker line was introduced to make impossible, arriving through the one door it
248
+ * does not cover. It reproduces from Node and not from an interactive shell, which is why it survived being read.
249
+ *
250
+ * The prefix that comes back with it (`./src/Button.vue`) is normalised away at the parse, so one spelling of a
251
+ * path reaches the chores no matter which tool produced it. */
252
+ const SCAN_ROOT = `.`;
253
+
254
+ const globArgs = (globs: readonly string[]): string => [...globs, ...SCAN_IGNORES].map((glob) => `-g '${glob}'`).join(` `);
255
+
256
+ // `path:count` from `rg --count-matches`, normalised. Split at the LAST colon: a path may contain one, a count is
257
+ // always the digits at the end.
258
+ const splitCount = (text: string): { path: string; count: number } | undefined => {
259
+ const at = text.lastIndexOf(`:`);
260
+ if (at <= 0) {
261
+ return undefined;
262
+ }
263
+ const count = Number.parseInt(text.slice(at + 1), 10);
264
+ return Number.isNaN(count) || count <= 0 ? undefined : { path: normalizePath(text.slice(0, at)), count };
265
+ };
266
+
267
+ /* An idiom's line is a PATH AND NOTHING ELSE, which is what lets one line shape carry both kinds of rule. A
268
+ * present rule asks ripgrep which files match (`-l`); an absent one asks which files do not (`--files-without-
269
+ * match`), and neither has a count to report. Nothing downstream ever wanted one: a file is on the old idiom or
270
+ * it is not, and how many times it says so within itself is not a fact anyone would act on differently. */
271
+ const idiomCommand = (rule: IdiomRule): string =>
272
+ `rg --no-messages ${rule.absent === undefined ? `-l` : `--files-without-match`} -e '${rule.pattern}' ${globArgs(rule.globs)} ${SCAN_ROOT} 2>/dev/null ` +
273
+ `| sort | head -n ${RULE_FILE_LIMIT} | awk '{print "IDIOM\\t${rule.id}\\t" $0}'`;
274
+
275
+ const scanCommand = (): string =>
276
+ [
277
+ `echo ${UI_MARKER}`,
278
+ `rg --files ${globArgs(COMPONENT_GLOBS)} ${SCAN_ROOT} 2>/dev/null | sort | head -n ${COMPONENT_LIMIT} | awk '{print "COMPONENT\\t" $0}'`,
279
+ `rg --no-messages --count-matches -e '${BYPASS_PATTERN}' ${globArgs(MARKUP_GLOBS)} ${SCAN_ROOT} 2>/dev/null ` +
280
+ `| sort | head -n ${RULE_FILE_LIMIT} | awk '{print "BYPASS\\t" $0}'`,
281
+ ...IDIOM_RULES.map(idiomCommand),
282
+ // Every rg above exits 1 when it matches nothing, which is the healthy case and must not read as a broken
283
+ // command. The runner judges by the parse, but leaving the script's own status at 1 would be a lie.
284
+ `true`,
285
+ ].join(`; `);
286
+
287
+ const parseUi = (stdout: string): ProbeFacts | undefined => {
288
+ const lines = stdout.split(`\n`).map((line) => line.trim());
289
+ if (lines.find((line) => line !== ``) !== UI_MARKER) {
290
+ return undefined;
291
+ }
292
+ const components: string[] = [];
293
+ const bypasses: UiScan["bypasses"] = [];
294
+ const byIdiom = new Map<string, string[]>();
295
+ for (const line of lines) {
296
+ const [label, ...rest] = line.split(`\t`);
297
+ if (label === `COMPONENT` && rest[0] !== undefined) {
298
+ components.push(normalizePath(rest[0]));
299
+ } else if (label === `BYPASS`) {
300
+ const hit = splitCount(rest.join(`\t`));
301
+ if (hit !== undefined) {
302
+ bypasses.push(hit);
303
+ }
304
+ } else if (label === `IDIOM` && rest[0] !== undefined) {
305
+ const path = normalizePath(rest.slice(1).join(`\t`));
306
+ if (path !== ``) {
307
+ byIdiom.set(rest[0], [...(byIdiom.get(rest[0]) ?? []), path]);
308
+ }
309
+ }
310
+ }
311
+ const scan: UiScan = { components, bypasses, idioms: [...byIdiom].map(([id, files]) => ({ id, files })) };
312
+ return { id: `ui`, scan };
313
+ };
314
+
315
+ /* THE BUILD OUTPUT, measured where it already is. See BundleSchema for why this never runs the build; the
316
+ * consequence here is that `available` is a question about the filesystem rather than about the toolchain, and a
317
+ * repository whose only builds happen in CI reports `unavailable` rather than a wrong number. */
318
+ const BUILD_DIRS = [`dist`, `build`, `out`, `public/build`];
319
+ const BUNDLE_MARKER = `DIR`;
320
+ // Enough of the ranking to see the shape of a build. Past this the assets are the long tail of lazy chunks, and
321
+ // carrying four hundred of them on a route the rail badge polls would cost more than the finding is worth.
322
+ const ASSET_LIMIT = 40;
323
+
324
+ const bundleCommand = (): string =>
325
+ [
326
+ `dir=""`,
327
+ `for d in ${BUILD_DIRS.join(` `)}; do if [ -d "$d" ]; then dir="$d"; break; fi; done`,
328
+ `[ -n "$dir" ] || exit 0`,
329
+ `printf '${BUNDLE_MARKER}\\t%s\\n' "$dir"`,
330
+ // `-exec ... {} +` rather than a `for` over command substitution: a hashed asset name will not contain a
331
+ // space, but a build that copies user content into the output can, and a probe is not the place to find
332
+ // out. Sorted by raw bytes so the head is the ranking rather than whatever order the walk returned.
333
+ `find "$dir" -type f \\( -name '*.js' -o -name '*.mjs' -o -name '*.cjs' -o -name '*.css' \\) ` +
334
+ `-exec sh -c 'for f; do printf "ASSET\\t%s\\t%s\\t%s\\n" "$(wc -c <"$f")" "$(gzip -c "$f" | wc -c)" "$f"; done' _ {} + ` +
335
+ `2>/dev/null | sort -k2 -rn | head -n ${ASSET_LIMIT}`,
336
+ ].join(`; `);
337
+
338
+ const parseBundle = (stdout: string): ProbeFacts | undefined => {
339
+ const lines = stdout.split(`\n`).map((line) => line.trim());
340
+ const dirLine = lines.find((line) => line.startsWith(`${BUNDLE_MARKER}\t`));
341
+ if (dirLine === undefined) {
342
+ return undefined;
343
+ }
344
+ const assets: Bundle["assets"] = [];
345
+ for (const line of lines) {
346
+ const [label, rawBytes, rawGzip, ...path] = line.split(`\t`);
347
+ if (label !== `ASSET` || path.length === 0) {
348
+ continue;
349
+ }
350
+ const bytes = Number.parseInt(rawBytes ?? ``, 10);
351
+ const gzip = Number.parseInt(rawGzip ?? ``, 10);
352
+ if (Number.isNaN(bytes) || Number.isNaN(gzip)) {
353
+ continue;
354
+ }
355
+ assets.push({ path: path.join(`\t`), bytes, gzip });
356
+ }
357
+ const bundle: Bundle = {
358
+ dir: dirLine.slice(BUNDLE_MARKER.length + 1),
359
+ // Of the assets CARRIED, which is the top of the ranking rather than the whole build. The chore says so
360
+ // when it quotes the number: a total that silently excluded the tail would be the more misleading of the
361
+ // two, and re-walking the tree to sum it would double the probe's cost for a figure nobody splits on.
362
+ totalBytes: assets.reduce((sum, asset) => sum + asset.bytes, 0),
363
+ totalGzip: assets.reduce((sum, asset) => sum + asset.gzip, 0),
364
+ assets,
365
+ };
366
+ return { id: `bundle`, bundle };
367
+ };
368
+
210
369
  // Where the tier-2 tools leave their reports. Under /tmp because they are inputs to a parse that happens
211
370
  // immediately after, never something to keep — the cached ProbeResult is the artefact that survives. The same
212
371
  // path the scheduled form of this chore uses (chores.ts), so a workspace running both keeps one copy.
@@ -271,6 +430,43 @@ export const PROBES: readonly ProbeSpec[] = [
271
430
  `cat ${JSCPD_DIR}/jscpd-report.json 2>/dev/null`,
272
431
  parse: parseJscpd,
273
432
  },
433
+ {
434
+ id: `ui`,
435
+ title: `Front-end source`,
436
+ measures: `components, hard-coded styles and idioms the framework has replaced`,
437
+ /* Tier 1 despite reading the whole tree, and the placement is a judgement rather than an oversight. The
438
+ * tier is about COST: this is a dozen ripgrep walks, seconds on a large monorepo, against knip
439
+ * type-checking the tree and jscpd tokenizing every file for minutes. A weekly TTL would also make it the
440
+ * wrong shape — its findings move whenever someone writes a component, which is daily. */
441
+ tier: 1,
442
+ ttlMs: DAY_MS,
443
+ timeoutMs: 5 * 60_000,
444
+ // Any manifest in the repo declaring a UI framework or Tailwind, not just the root's — a monorepo keeps
445
+ // React in the app package and the root manifest is a handful of build tools.
446
+ available:
447
+ `rg -l --no-messages -g '**/package.json' -g '!**/node_modules/**' ` +
448
+ `-e '[\\x22](${[...UI_FRAMEWORKS.flatMap((framework) => framework.packages), ...TAILWIND_PACKAGES].join(`|`)})[\\x22]\\s*:' . >/dev/null`,
449
+ unavailable: `no package here declares a UI framework or Tailwind`,
450
+ command: scanCommand(),
451
+ parse: parseUi,
452
+ },
453
+ {
454
+ id: `bundle`,
455
+ title: `Build output`,
456
+ measures: `what the last build put on disk for a browser to download`,
457
+ tier: 1,
458
+ ttlMs: DAY_MS,
459
+ timeoutMs: 5 * 60_000,
460
+ // A build directory that actually contains something a browser would download. `-d` alone would pass on
461
+ // the empty `dist/` a cleaned checkout leaves behind, and the measurement would report a zero-byte bundle
462
+ // as a fact about the application.
463
+ available: `find ${BUILD_DIRS.join(` `)} -maxdepth 4 -type f \\( -name '*.js' -o -name '*.mjs' -o -name '*.css' \\) 2>/dev/null | head -n 1 | grep -q .`,
464
+ // Says what is missing AND that this never builds, because the obvious reading of "no build output" is
465
+ // that we tried and it failed. The owner running their own build once is the whole fix.
466
+ unavailable: `no build output on disk — this reads the last build, it never runs one`,
467
+ command: bundleCommand(),
468
+ parse: parseBundle,
469
+ },
274
470
  ];
275
471
 
276
472
  export const probeSpec = (id: ProbeId): ProbeSpec => {
@@ -0,0 +1,126 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { componentStem, frameworksOf, IDIOM_RULES, idiomRule, UI_FRAMEWORKS, usesTailwind } from "./stack.js";
3
+
4
+ /* The table's own invariants, and the name normaliser under it.
5
+ *
6
+ * Most of what is below guards a failure that CANNOT be seen by reading the table: a pattern is interpolated into
7
+ * a shell command that runs on someone else's machine at three in the morning, so a stray quote is not a typo
8
+ * anyone reviews — it is a probe that dies in a workspace nobody is watching, with a shell error for a reason. */
9
+
10
+ describe(`the patterns are safe to interpolate`, () => {
11
+ // The scan wraps every pattern and glob in shell single quotes. One apostrophe inside ends the quoting and
12
+ // hands the remainder of the regex to sh, which is how a pattern becomes a command.
13
+ test(`no pattern or glob contains an apostrophe`, () => {
14
+ for (const rule of IDIOM_RULES) {
15
+ expect(rule.pattern, rule.id).not.toContain(`'`);
16
+ for (const glob of rule.globs) {
17
+ expect(glob, rule.id).not.toContain(`'`);
18
+ }
19
+ }
20
+ });
21
+
22
+ // Not the dialect ripgrep will use, but it catches the unbalanced bracket and the stray backslash, which is
23
+ // what actually goes wrong when someone adds a rule.
24
+ test(`every pattern parses as a regex`, () => {
25
+ for (const rule of IDIOM_RULES) {
26
+ expect(() => new RegExp(rule.pattern), rule.id).not.toThrow();
27
+ }
28
+ });
29
+
30
+ /* Rust's regex crate has no lookaround, and getting it means ripgrep's -P, which is a compile-time option on
31
+ * the box the sweep happens to run on. A rule that seems to need one is asking a question about the FILE
32
+ * rather than about a line — which is what `absent` is. */
33
+ test(`no pattern uses a lookaround`, () => {
34
+ for (const rule of IDIOM_RULES) {
35
+ expect(rule.pattern, rule.id).not.toMatch(/\(\?<?[=!]/);
36
+ }
37
+ });
38
+
39
+ /* An absent rule's population is every file its globs match, so a glob that is merely broad on a normal rule
40
+ * is catastrophic on this one: `*.ts` would name every TypeScript file in the repository as legacy code. A
41
+ * component extension is safe because every file wearing it is the thing the migration is about. */
42
+ test(`an absent rule is scoped to a component file type, never to a whole language`, () => {
43
+ for (const rule of IDIOM_RULES.filter((candidate) => candidate.absent !== undefined)) {
44
+ for (const glob of rule.globs) {
45
+ expect([`*.ts`, `*.js`, `*.mts`, `*.cts`, `*.mjs`, `*.cjs`], rule.id).not.toContain(glob);
46
+ }
47
+ }
48
+ });
49
+
50
+ test(`every rule has a unique id and belongs to a framework in the table`, () => {
51
+ const ids = new Set(IDIOM_RULES.map((rule) => rule.id));
52
+ expect(ids.size).toBe(IDIOM_RULES.length);
53
+ for (const rule of IDIOM_RULES) {
54
+ expect(
55
+ UI_FRAMEWORKS.map((framework) => framework.id),
56
+ rule.id,
57
+ ).toContain(rule.framework);
58
+ }
59
+ });
60
+
61
+ // The chore names the replacement in its prompt; a rule without one would wake an agent, tell it what to stop
62
+ // doing and leave it to guess a destination.
63
+ test(`every rule names what replaced it`, () => {
64
+ for (const rule of IDIOM_RULES) {
65
+ expect(rule.replacement.length, rule.id).toBeGreaterThan(3);
66
+ }
67
+ });
68
+
69
+ test(`idiomRule finds a rule the scan reports, and admits when it cannot`, () => {
70
+ expect(idiomRule(`vue-options-api`)?.framework).toBe(`vue`);
71
+ expect(idiomRule(`from-a-newer-daemon`)).toBeUndefined();
72
+ });
73
+ });
74
+
75
+ describe(`recognising the stack`, () => {
76
+ test(`a framework is recognised from any manifest's dependency names`, () => {
77
+ expect(frameworksOf([`vue`, `vite`]).map((framework) => framework.id)).toEqual([`vue`]);
78
+ expect(frameworksOf([`react`, `@angular/core`]).map((framework) => framework.id)).toEqual([`react`, `angular`]);
79
+ expect(frameworksOf([`pino`, `zod`])).toEqual([]);
80
+ });
81
+
82
+ // A near-miss must not read as a hit: plenty of packages are named after the framework they plug into, and
83
+ // `@vueuse/core` in a repo with no Vue is a dependency somebody left behind rather than a Vue application.
84
+ test(`a package merely named after a framework is not that framework`, () => {
85
+ expect(frameworksOf([`@vueuse/core`, `react-hook-form`, `eslint-plugin-vue`])).toEqual([]);
86
+ });
87
+
88
+ test(`Tailwind is recognised on its own, without a framework`, () => {
89
+ expect(usesTailwind([`tailwindcss`])).toBe(true);
90
+ expect(usesTailwind([`@tailwindcss/typography`])).toBe(false);
91
+ });
92
+ });
93
+
94
+ /* THE NORMALISER, which is the whole evidence of the component-overlap chore and therefore the place a false
95
+ * finding would come from. Each case below is a family that must form, or one that must not. */
96
+ describe(`the name two components share`, () => {
97
+ test(`framework and qualifier noise falls away`, () => {
98
+ expect(componentStem(`src/components/Button.vue`)).toBe(`button`);
99
+ expect(componentStem(`src/ui/BaseButton.vue`)).toBe(`button`);
100
+ expect(componentStem(`src/legacy/ButtonV2.tsx`)).toBe(`button`);
101
+ expect(componentStem(`src/app/user-card.component.ts`)).toBe(`usercard`);
102
+ expect(componentStem(`src/UserCard.tsx`)).toBe(`usercard`);
103
+ });
104
+
105
+ // Every barrel file in the repository is called this. A family of forty is a fact about the convention, not
106
+ // about duplication, and it would be the largest finding in every repo that has one.
107
+ test(`index files never form a family`, () => {
108
+ expect(componentStem(`src/components/Button/index.tsx`)).toBeUndefined();
109
+ });
110
+
111
+ /* The trap in stripping a trailing number. `H1` and `H2` are different components and reduce to the same
112
+ * single letter, so the stem is only accepted when what survives is still long enough to mean something —
113
+ * otherwise the untouched name is kept and the two stay apart. */
114
+ test(`short names keep their digits rather than collapsing together`, () => {
115
+ expect(componentStem(`src/type/H1.tsx`)).toBe(`h1`);
116
+ expect(componentStem(`src/type/H2.tsx`)).toBe(`h2`);
117
+ expect(componentStem(`src/type/H1.tsx`)).not.toBe(componentStem(`src/type/H2.tsx`));
118
+ });
119
+
120
+ // Same guard on the prefix side: `Theme` begins with `the`, and stripping it would leave `me` and put Theme
121
+ // in a family with anything else that reduced to it.
122
+ test(`a word that merely starts with a qualifier is left alone`, () => {
123
+ expect(componentStem(`src/Theme.tsx`)).toBe(`theme`);
124
+ expect(componentStem(`src/TheHeader.vue`)).toBe(`header`);
125
+ });
126
+ });