@turri-dev/cli 0.0.0-stage → 0.1.2-next.48dacfc

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 (315) hide show
  1. package/.pi/themes/turri-dark.json +80 -0
  2. package/.pi/themes/turri-light.json +82 -0
  3. package/LICENSE +21 -0
  4. package/README.md +272 -2
  5. package/assets/jellyfish/dark-00.png +0 -0
  6. package/assets/jellyfish/dark-01.png +0 -0
  7. package/assets/jellyfish/dark-02.png +0 -0
  8. package/assets/jellyfish/dark-03.png +0 -0
  9. package/assets/jellyfish/dark-04.png +0 -0
  10. package/assets/jellyfish/dark-05.png +0 -0
  11. package/assets/jellyfish/dark-06.png +0 -0
  12. package/assets/jellyfish/dark-07.png +0 -0
  13. package/assets/jellyfish/dark-08.png +0 -0
  14. package/assets/jellyfish/dark-09.png +0 -0
  15. package/assets/jellyfish/dark-10.png +0 -0
  16. package/assets/jellyfish/dark-11.png +0 -0
  17. package/assets/jellyfish/dark-12.png +0 -0
  18. package/assets/jellyfish/dark-13.png +0 -0
  19. package/assets/jellyfish/dark-14.png +0 -0
  20. package/assets/jellyfish/fallback.txt +252 -0
  21. package/assets/jellyfish/light-00.png +0 -0
  22. package/assets/jellyfish/light-01.png +0 -0
  23. package/assets/jellyfish/light-02.png +0 -0
  24. package/assets/jellyfish/light-03.png +0 -0
  25. package/assets/jellyfish/light-04.png +0 -0
  26. package/assets/jellyfish/light-05.png +0 -0
  27. package/assets/jellyfish/light-06.png +0 -0
  28. package/assets/jellyfish/light-07.png +0 -0
  29. package/assets/jellyfish/light-08.png +0 -0
  30. package/assets/jellyfish/light-09.png +0 -0
  31. package/assets/jellyfish/light-10.png +0 -0
  32. package/assets/jellyfish/light-11.png +0 -0
  33. package/assets/jellyfish/light-12.png +0 -0
  34. package/assets/jellyfish/light-13.png +0 -0
  35. package/assets/jellyfish/light-14.png +0 -0
  36. package/bin/turri.mjs +29 -0
  37. package/dist/ask.js +149 -0
  38. package/dist/brand.js +50 -0
  39. package/dist/btw.js +174 -0
  40. package/dist/chat-models.js +37 -0
  41. package/dist/chat-skills.js +18 -0
  42. package/dist/cli.js +178 -0
  43. package/dist/config-command.js +197 -0
  44. package/dist/config-edit.js +367 -0
  45. package/dist/config.js +489 -0
  46. package/dist/conform-cli.js +29 -0
  47. package/dist/cultivate-cli.js +110 -0
  48. package/dist/drift-cli.js +25 -0
  49. package/dist/environment.js +70 -0
  50. package/dist/extension.js +2372 -0
  51. package/dist/genome.js +301 -0
  52. package/dist/graph.js +246 -0
  53. package/dist/identity.js +35 -0
  54. package/dist/init-config.js +511 -0
  55. package/dist/judge.js +62 -0
  56. package/dist/lineage-cli.js +13 -0
  57. package/dist/loop-cli.js +132 -0
  58. package/dist/loop-tools.js +225 -0
  59. package/dist/mature-tools.js +193 -0
  60. package/dist/model-check.js +117 -0
  61. package/dist/module-report.js +9 -0
  62. package/dist/pi-launch.js +31 -0
  63. package/dist/pi.js +813 -0
  64. package/dist/plain/check.js +76 -0
  65. package/dist/plain/concepts.js +213 -0
  66. package/dist/plain/errors.js +43 -0
  67. package/dist/plain/files.js +57 -0
  68. package/dist/plain/highlight.js +195 -0
  69. package/dist/plain/index.js +15 -0
  70. package/dist/plain/liquid.js +129 -0
  71. package/dist/plain/markdown.js +208 -0
  72. package/dist/plain/parser.js +752 -0
  73. package/dist/plain/plain.tmLanguage.json +124 -0
  74. package/dist/plain/spec.js +291 -0
  75. package/dist/plain/stdlib.js +44 -0
  76. package/dist/question-dialog.js +125 -0
  77. package/dist/questioning.js +109 -0
  78. package/dist/quiet.js +125 -0
  79. package/dist/regenerate/activity.js +187 -0
  80. package/dist/regenerate/agent-env.js +190 -0
  81. package/dist/regenerate/agent-shell.js +114 -0
  82. package/dist/regenerate/arbiter.js +133 -0
  83. package/dist/regenerate/audit.js +203 -0
  84. package/dist/regenerate/cases.js +83 -0
  85. package/dist/regenerate/change-view.js +102 -0
  86. package/dist/regenerate/choose-conformance.js +117 -0
  87. package/dist/regenerate/chunk-plan.js +625 -0
  88. package/dist/regenerate/codebase.js +75 -0
  89. package/dist/regenerate/command-check.js +312 -0
  90. package/dist/regenerate/compaction.js +20 -0
  91. package/dist/regenerate/confine.js +310 -0
  92. package/dist/regenerate/conform.js +133 -0
  93. package/dist/regenerate/conformance-agents.js +955 -0
  94. package/dist/regenerate/conformance.js +936 -0
  95. package/dist/regenerate/conversation-writes.js +100 -0
  96. package/dist/regenerate/credentials.js +88 -0
  97. package/dist/regenerate/debug-log.js +228 -0
  98. package/dist/regenerate/debugger.js +90 -0
  99. package/dist/regenerate/delivery.js +19 -0
  100. package/dist/regenerate/diagnose-job.js +40 -0
  101. package/dist/regenerate/diagnose.js +81 -0
  102. package/dist/regenerate/diff-view.js +240 -0
  103. package/dist/regenerate/drift.js +194 -0
  104. package/dist/regenerate/engine.js +118 -0
  105. package/dist/regenerate/environment-error.js +220 -0
  106. package/dist/regenerate/job-log.js +102 -0
  107. package/dist/regenerate/job-panel.js +336 -0
  108. package/dist/regenerate/jobs-tree.js +300 -0
  109. package/dist/regenerate/jobs.js +427 -0
  110. package/dist/regenerate/lanes.js +32 -0
  111. package/dist/regenerate/lineage.js +384 -0
  112. package/dist/regenerate/loop.js +1435 -0
  113. package/dist/regenerate/masking.js +95 -0
  114. package/dist/regenerate/notes.js +397 -0
  115. package/dist/regenerate/os-sandbox.js +243 -0
  116. package/dist/regenerate/paths.js +165 -0
  117. package/dist/regenerate/plain-palette.js +83 -0
  118. package/dist/regenerate/prompt.js +704 -0
  119. package/dist/regenerate/records.js +294 -0
  120. package/dist/regenerate/regenerate.js +598 -0
  121. package/dist/regenerate/regression.js +181 -0
  122. package/dist/regenerate/release.js +262 -0
  123. package/dist/regenerate/requires.js +241 -0
  124. package/dist/regenerate/resources.js +164 -0
  125. package/dist/regenerate/self-check.js +111 -0
  126. package/dist/regenerate/services.js +155 -0
  127. package/dist/regenerate/shell.js +320 -0
  128. package/dist/regenerate/side-panel.js +278 -0
  129. package/dist/regenerate/spec-map-view.js +574 -0
  130. package/dist/regenerate/spec-map.js +561 -0
  131. package/dist/regenerate/spec-steps.js +527 -0
  132. package/dist/regenerate/state.js +10 -0
  133. package/dist/regenerate/steer.js +91 -0
  134. package/dist/regenerate/steering.js +119 -0
  135. package/dist/regenerate/stop-report.js +161 -0
  136. package/dist/regenerate/submit-work.js +71 -0
  137. package/dist/regenerate/test-kinds.js +124 -0
  138. package/dist/regenerate/test-scripts.js +695 -0
  139. package/dist/regenerate/testing.js +406 -0
  140. package/dist/regenerate/transcript-view.js +1 -0
  141. package/dist/regenerate/unit-gate.js +20 -0
  142. package/dist/regenerate/unit-review.js +347 -0
  143. package/dist/regenerate/web.js +256 -0
  144. package/dist/regenerate/workspace.js +99 -0
  145. package/dist/regenerate-cli.js +68 -0
  146. package/dist/release-agent.js +114 -0
  147. package/dist/release-cli.js +131 -0
  148. package/dist/release-tools.js +148 -0
  149. package/dist/review/live.js +165 -0
  150. package/dist/rules.js +47 -0
  151. package/dist/self-invocation.js +110 -0
  152. package/dist/setup.js +245 -0
  153. package/dist/skill-progress.js +78 -0
  154. package/dist/skill.js +149 -0
  155. package/dist/skills.js +74 -0
  156. package/dist/spec-diff-cli.js +37 -0
  157. package/dist/spec-tools.js +155 -0
  158. package/dist/stack.js +158 -0
  159. package/dist/stacks.js +97 -0
  160. package/dist/stdlib.js +100 -0
  161. package/dist/terminal-images.js +46 -0
  162. package/dist/tools.js +242 -0
  163. package/dist/validate-cli.js +52 -0
  164. package/docs/README.md +58 -0
  165. package/docs/authoring-plain.md +245 -0
  166. package/docs/config.md +353 -0
  167. package/docs/deploy.md +109 -0
  168. package/docs/development.md +88 -0
  169. package/docs/headless.md +148 -0
  170. package/docs/images/how-turri-works.svg +50 -0
  171. package/docs/images/turri-banner.png +0 -0
  172. package/docs/installation.md +119 -0
  173. package/docs/parser.md +108 -0
  174. package/docs/regenerating.md +1116 -0
  175. package/docs/sandbox.md +233 -0
  176. package/docs/skills-and-gates.md +254 -0
  177. package/docs/theming.md +76 -0
  178. package/docs/using.md +454 -0
  179. package/genome/rules/bullet-continuation.md +69 -0
  180. package/genome/rules/concise-specs-examples.md +431 -0
  181. package/genome/rules/concise-specs-integration-examples.md +270 -0
  182. package/genome/rules/concise-specs.md +80 -0
  183. package/genome/rules/definitions.md +107 -0
  184. package/genome/rules/examples/integration-embedded-testing/run_conformance_tests_java.sh +163 -0
  185. package/genome/rules/examples/integration-embedded-testing/run_unittests_java.sh +51 -0
  186. package/genome/rules/exported-concepts.md +39 -0
  187. package/genome/rules/func-specs.md +129 -0
  188. package/genome/rules/impl-reqs.md +68 -0
  189. package/genome/rules/import-modules.md +48 -0
  190. package/genome/rules/integration-embedded-testing.md +276 -0
  191. package/genome/rules/integration-embedded.md +171 -0
  192. package/genome/rules/integration-standalone.md +135 -0
  193. package/genome/rules/integrations.md +137 -0
  194. package/genome/rules/linked-resources.md +69 -0
  195. package/genome/rules/module-structure.md +105 -0
  196. package/genome/rules/project-model.md +88 -0
  197. package/genome/rules/required-concepts.md +44 -0
  198. package/genome/rules/requires-modules.md +80 -0
  199. package/genome/rules/stdlib.md +73 -0
  200. package/genome/rules/test-reqs.md +57 -0
  201. package/genome/skills/add-acceptance-test/SKILL.md +100 -0
  202. package/genome/skills/add-concept/SKILL.md +71 -0
  203. package/genome/skills/add-functional-spec/SKILL.md +84 -0
  204. package/genome/skills/add-functional-specs/SKILL.md +118 -0
  205. package/genome/skills/add-implementation-requirement/SKILL.md +86 -0
  206. package/genome/skills/add-resource/SKILL.md +105 -0
  207. package/genome/skills/add-template/SKILL.md +64 -0
  208. package/genome/skills/add-test-requirement/SKILL.md +73 -0
  209. package/genome/skills/analyze-2-func-specs/SKILL.md +101 -0
  210. package/genome/skills/analyze-func-specs/SKILL.md +123 -0
  211. package/genome/skills/analyze-if-func-spec-too-complex/SKILL.md +211 -0
  212. package/genome/skills/break-down-func-spec/SKILL.md +205 -0
  213. package/genome/skills/brood/SKILL.md +229 -0
  214. package/genome/skills/brood/references/intent.md +35 -0
  215. package/genome/skills/brood/references/product.md +101 -0
  216. package/genome/skills/brood/references/stack.md +61 -0
  217. package/genome/skills/brood/references/testing.md +56 -0
  218. package/genome/skills/bud/SKILL.md +148 -0
  219. package/genome/skills/bud/references/checklist.md +44 -0
  220. package/genome/skills/bud/references/intent.md +30 -0
  221. package/genome/skills/check-plain-env/SKILL.md +285 -0
  222. package/genome/skills/consolidate-concepts/SKILL.md +192 -0
  223. package/genome/skills/create-import-module/SKILL.md +104 -0
  224. package/genome/skills/create-requires-module/SKILL.md +109 -0
  225. package/genome/skills/debug-specs/SKILL.md +188 -0
  226. package/genome/skills/implement-conformance-testing-script/SKILL.md +238 -0
  227. package/genome/skills/implement-conformance-testing-script/assets/run_conformance_tests_cypress.ps1 +337 -0
  228. package/genome/skills/implement-conformance-testing-script/assets/run_conformance_tests_golang.ps1 +111 -0
  229. package/genome/skills/implement-conformance-testing-script/assets/run_conformance_tests_java.sh +136 -0
  230. package/genome/skills/implement-conformance-testing-script/assets/run_conformance_tests_python.ps1 +103 -0
  231. package/genome/skills/implement-conformance-testing-script/assets/run_conformance_tests_python.sh +150 -0
  232. package/genome/skills/implement-unit-testing-script/SKILL.md +160 -0
  233. package/genome/skills/implement-unit-testing-script/assets/run_unittests_flutter.ps1 +87 -0
  234. package/genome/skills/implement-unit-testing-script/assets/run_unittests_golang.ps1 +73 -0
  235. package/genome/skills/implement-unit-testing-script/assets/run_unittests_java.sh +72 -0
  236. package/genome/skills/implement-unit-testing-script/assets/run_unittests_python.ps1 +81 -0
  237. package/genome/skills/implement-unit-testing-script/assets/run_unittests_python.sh +98 -0
  238. package/genome/skills/implement-unit-testing-script/assets/run_unittests_react.ps1 +87 -0
  239. package/genome/skills/init-plain-project/SKILL.md +117 -0
  240. package/genome/skills/init-plain-project/references/checklist.md +30 -0
  241. package/genome/skills/mature/SKILL.md +126 -0
  242. package/genome/skills/refactor-module/SKILL.md +196 -0
  243. package/genome/skills/resolve-section-ownership/SKILL.md +64 -0
  244. package/genome/skills/resolve-spec-conflict/SKILL.md +96 -0
  245. package/genome/skills/spawn/SKILL.md +163 -0
  246. package/package.json +68 -4
  247. package/stdlib/backend/common.plain +25 -0
  248. package/stdlib/backend/conformance/common.plain +12 -0
  249. package/stdlib/backend/conformance/go-gin.plain +9 -0
  250. package/stdlib/backend/conformance/go-nethttp.plain +9 -0
  251. package/stdlib/backend/conformance/java-springboot.plain +9 -0
  252. package/stdlib/backend/conformance/python-django.plain +9 -0
  253. package/stdlib/backend/conformance/python-fastapi.plain +9 -0
  254. package/stdlib/backend/conformance/python-flask.plain +9 -0
  255. package/stdlib/backend/conformance/rust-axum.plain +9 -0
  256. package/stdlib/backend/conformance/typescript-express.plain +9 -0
  257. package/stdlib/backend/conformance/typescript-fastify.plain +9 -0
  258. package/stdlib/backend/conformance/typescript-nestjs.plain +9 -0
  259. package/stdlib/backend/go-gin.plain +18 -0
  260. package/stdlib/backend/go-nethttp.plain +18 -0
  261. package/stdlib/backend/java-springboot.plain +19 -0
  262. package/stdlib/backend/python-django.plain +19 -0
  263. package/stdlib/backend/python-fastapi.plain +23 -0
  264. package/stdlib/backend/python-flask.plain +23 -0
  265. package/stdlib/backend/rust-axum.plain +17 -0
  266. package/stdlib/backend/typescript-express.plain +19 -0
  267. package/stdlib/backend/typescript-fastify.plain +19 -0
  268. package/stdlib/backend/typescript-nestjs.plain +19 -0
  269. package/stdlib/cli/common.plain +17 -0
  270. package/stdlib/cli/conformance/common.plain +11 -0
  271. package/stdlib/cli/conformance/go-cobra.plain +9 -0
  272. package/stdlib/cli/conformance/go-flag.plain +9 -0
  273. package/stdlib/cli/conformance/java-picocli.plain +9 -0
  274. package/stdlib/cli/conformance/python-argparse.plain +9 -0
  275. package/stdlib/cli/conformance/python-click.plain +9 -0
  276. package/stdlib/cli/conformance/python-typer.plain +9 -0
  277. package/stdlib/cli/conformance/rust-clap.plain +9 -0
  278. package/stdlib/cli/conformance/typescript-commander.plain +9 -0
  279. package/stdlib/cli/go-cobra.plain +14 -0
  280. package/stdlib/cli/go-flag.plain +14 -0
  281. package/stdlib/cli/java-picocli.plain +15 -0
  282. package/stdlib/cli/python-argparse.plain +18 -0
  283. package/stdlib/cli/python-click.plain +16 -0
  284. package/stdlib/cli/python-typer.plain +16 -0
  285. package/stdlib/cli/rust-clap.plain +14 -0
  286. package/stdlib/cli/typescript-commander.plain +17 -0
  287. package/stdlib/frontend/angular.plain +17 -0
  288. package/stdlib/frontend/common.plain +17 -0
  289. package/stdlib/frontend/conformance/angular.plain +5 -0
  290. package/stdlib/frontend/conformance/common.plain +11 -0
  291. package/stdlib/frontend/conformance/react-vite.plain +5 -0
  292. package/stdlib/frontend/conformance/sveltekit-spa.plain +5 -0
  293. package/stdlib/frontend/conformance/vue-vite.plain +5 -0
  294. package/stdlib/frontend/react-vite.plain +16 -0
  295. package/stdlib/frontend/sveltekit-spa.plain +16 -0
  296. package/stdlib/frontend/vue-vite.plain +16 -0
  297. package/stdlib/lang/go.plain +13 -0
  298. package/stdlib/lang/java.plain +12 -0
  299. package/stdlib/lang/python.plain +18 -0
  300. package/stdlib/lang/rust.plain +12 -0
  301. package/stdlib/lang/typescript.plain +13 -0
  302. package/stdlib/library/common.plain +15 -0
  303. package/stdlib/library/conformance/common.plain +9 -0
  304. package/stdlib/library/conformance/go.plain +9 -0
  305. package/stdlib/library/conformance/java-gradle.plain +9 -0
  306. package/stdlib/library/conformance/java-maven.plain +9 -0
  307. package/stdlib/library/conformance/python.plain +9 -0
  308. package/stdlib/library/conformance/rust.plain +9 -0
  309. package/stdlib/library/conformance/typescript.plain +9 -0
  310. package/stdlib/library/go.plain +12 -0
  311. package/stdlib/library/java-gradle.plain +13 -0
  312. package/stdlib/library/java-maven.plain +13 -0
  313. package/stdlib/library/python.plain +13 -0
  314. package/stdlib/library/rust.plain +12 -0
  315. package/stdlib/library/typescript.plain +15 -0
@@ -0,0 +1,695 @@
1
+ import { fixMethod } from "./prompt.js";
2
+ import { randomUUID } from "node:crypto";
3
+ import { accessSync, constants, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
4
+ import { basename, delimiter, dirname, join, resolve, sep } from "node:path";
5
+ import { CONFIG_FILE, CONFIG_KEYS, projectConfigFile, readProjectConfig, stackConfigFileIn, stackOf } from "../config.js";
6
+ import { reportActivity, reportLogNote, reportNote } from "./activity.js";
7
+ import { ensureInModulesScratch, modulesScratchDir } from "./paths.js";
8
+ import { defineTool } from "@earendil-works/pi-coding-agent";
9
+ import { Type } from "typebox";
10
+ import { keepTestOutput, listFiles, shownTestOutput } from "./testing.js";
11
+ import { digestFiles } from "./codebase.js";
12
+ import { NOTES_FILE } from "./notes.js";
13
+ import { COMMAND_TIMEOUT_MS, runCommand } from "./shell.js";
14
+ /**
15
+ * The scripts, with the conformance testing script set aside because conformance was turned
16
+ * off: no conformance tests are written or run - not even those already in the codebase - and
17
+ * no agent is shown it.
18
+ */
19
+ export function withoutConformance(scripts) {
20
+ const { conformance: _ignored, prepare: _alsoIgnored, ...rest } = scripts;
21
+ return { ...rest, conformanceOff: true };
22
+ }
23
+ /** The names the skills give each script. Only a file by one of these names is ever run. */
24
+ const NAMES = {
25
+ unit: /^run_unit_?tests[^/]*\.sh$/i,
26
+ conformance: /^run_conformance_?tests[^/]*\.sh$/i,
27
+ };
28
+ export function scriptKind(name) {
29
+ return Object.keys(NAMES).find((kind) => NAMES[kind].test(basename(name)));
30
+ }
31
+ /** The name the prepare script has: `prepare_environment_<lang>.sh`. */
32
+ const PREPARE_NAME = /^prepare_environment[^/]*\.sh$/i;
33
+ function isFile(path) {
34
+ try {
35
+ return statSync(path).isFile();
36
+ }
37
+ catch {
38
+ return false;
39
+ }
40
+ }
41
+ /** The key in the project's config.turri.yaml that names each script. */
42
+ const SCRIPT_KEYS = {
43
+ unit: CONFIG_KEYS.unittestsScript,
44
+ conformance: CONFIG_KEYS.conformanceTestsScript,
45
+ };
46
+ export const SCRIPTS_DIR = "test_scripts";
47
+ /**
48
+ * The project a build belongs to: the folder that holds its plain_modules. A build folder
49
+ * elsewhere belongs to the folder of its spec.
50
+ */
51
+ export function projectRootFor(plainFile, buildDir) {
52
+ const parts = resolve(buildDir).split(sep);
53
+ const index = parts.lastIndexOf("plain_modules");
54
+ return index > 0 ? parts.slice(0, index).join(sep) : dirname(resolve(plainFile));
55
+ }
56
+ /** The one script of a kind in a folder, by the names the skills give them; two or more say which. */
57
+ function onlyScriptIn(dir, kind) {
58
+ if (!existsSync(dir))
59
+ return {};
60
+ const candidates = readdirSync(dir)
61
+ .filter((name) => (kind === "prepare" ? PREPARE_NAME.test(name) : scriptKind(name) === kind) && isFile(join(dir, name)))
62
+ .sort();
63
+ if (candidates.length === 1)
64
+ return { path: join(dir, candidates[0]) };
65
+ return candidates.length > 1 ? { several: candidates } : {};
66
+ }
67
+ /**
68
+ * The project's testing scripts, looked for in this order:
69
+ *
70
+ * 1. `config.turri.yaml` at the project root, with the chosen stack's `config.turri.<stack>.yaml` over it -
71
+ * `unittests-script:` and `conformance-tests-script:`, paths from the root;
72
+ * 2. the project root itself - the one `run_unittests*.sh` / `run_conformance_tests*.sh` there;
73
+ * 3. `test_scripts/` - the one of each there.
74
+ *
75
+ * The prepare script - `prepare-environment-script:`, else the one `prepare_environment*.sh` - is looked for the same
76
+ * way, and is optional: without one, the conformance testing script runs alone.
77
+ *
78
+ * A folder holding two of a kind does not say which is meant: it is skipped, and noted, so the
79
+ * error that stops the build can say to name one in config.turri.yaml.
80
+ */
81
+ export function findTestScripts(root, plainFile, buildDir) {
82
+ const found = {};
83
+ const notes = {};
84
+ // The stack chosen, its file over the project's, when the project has one for it: its scripts.
85
+ const stack = stackOf(root);
86
+ const config = readProjectConfig(root, stack);
87
+ const file = stack !== undefined ? `${stackConfigFileIn(root, stack)} (over ${projectConfigFile(root)})` : projectConfigFile(root);
88
+ // Where a script is named: the stack's file, once one is chosen.
89
+ const nameIn = stack !== undefined ? stackConfigFileIn(root, stack) : `${CONFIG_FILE} - or, for a project of several stacks, the stack's config.turri.<stack>.yaml`;
90
+ if (config.testingScriptsRunOn !== undefined)
91
+ found.runOn = config.testingScriptsRunOn;
92
+ for (const kind of Object.keys(SCRIPT_KEYS)) {
93
+ const named = kind === "unit" ? config.unittestsScript : config.conformanceTestsScript;
94
+ if (named !== undefined) {
95
+ const path = resolve(root, named.trim());
96
+ // Agents write in plain_modules: a script there could be changed by the work it judges.
97
+ if (path.split(sep).includes("plain_modules")) {
98
+ notes[kind] = `${file} names ${named.trim()} as the ${SCRIPT_KEYS[kind]}, inside plain_modules - where agents write, so it is not used.`;
99
+ }
100
+ else if (isFile(path)) {
101
+ found[kind] = path;
102
+ continue;
103
+ }
104
+ else {
105
+ notes[kind] = `${file} names ${named.trim()} as the ${SCRIPT_KEYS[kind]}, and there is no such file.`;
106
+ }
107
+ }
108
+ for (const dir of [root, join(root, SCRIPTS_DIR)]) {
109
+ const { path, several } = onlyScriptIn(dir, kind);
110
+ if (path !== undefined) {
111
+ found[kind] = path;
112
+ break;
113
+ }
114
+ if (several !== undefined && notes[kind] === undefined) {
115
+ notes[kind] = `${dir === root ? "The project root" : `${SCRIPTS_DIR}/`} holds ${several.join(", ")} - name the one to use in ${nameIn}.`;
116
+ }
117
+ }
118
+ }
119
+ // Optional: found the same way, and nothing is missing without it.
120
+ const prepareNamed = config.prepareEnvironmentScript;
121
+ const preparePath = prepareNamed !== undefined ? resolve(root, prepareNamed.trim()) : undefined;
122
+ if (preparePath !== undefined && preparePath.split(sep).includes("plain_modules")) {
123
+ notes.prepare = `${file} names ${prepareNamed.trim()} as the ${CONFIG_KEYS.prepareEnvironmentScript}, inside plain_modules - where agents write, so it is not used.`;
124
+ }
125
+ else if (preparePath !== undefined && isFile(preparePath)) {
126
+ found.prepare = preparePath;
127
+ }
128
+ else {
129
+ if (preparePath !== undefined)
130
+ notes.prepare = `${file} names ${prepareNamed.trim()} as the ${CONFIG_KEYS.prepareEnvironmentScript}, and there is no such file.`;
131
+ for (const dir of [root, join(root, SCRIPTS_DIR)]) {
132
+ const { path, several } = onlyScriptIn(dir, "prepare");
133
+ if (path !== undefined) {
134
+ found.prepare = path;
135
+ break;
136
+ }
137
+ if (several !== undefined && notes.prepare === undefined) {
138
+ notes.prepare = `${dir === root ? "The project root" : `${SCRIPTS_DIR}/`} holds ${several.join(", ")} - name the one to use in ${nameIn}, as ${CONFIG_KEYS.prepareEnvironmentScript}:.`;
139
+ }
140
+ }
141
+ }
142
+ if (Object.keys(notes).length > 0)
143
+ found.notes = notes;
144
+ return found;
145
+ }
146
+ /**
147
+ * The exit code a testing script ends with when the tests passed and only its static checks - the formatter in check
148
+ * mode, the linter, the type checker - did not. Every other failure is the script's own exit code. The harness tells the
149
+ * two apart: a unit run says it was the static checks, and a conformance run sends its tests' findings straight to the
150
+ * conformance agent, with no judge asked whether the code or the tests are wrong.
151
+ */
152
+ export const STATIC_CHECKS_FAILED = 80;
153
+ /** Whether a failed run of a testing script failed on its static checks alone: the tests passed. */
154
+ export function staticChecksOnly(run) {
155
+ return run.exitCode === STATIC_CHECKS_FAILED && run.timedOut !== true;
156
+ }
157
+ const SKILL = {
158
+ unit: { skill: "implement-unit-testing-script", file: "run_unittests", what: "unit testing script" },
159
+ conformance: { skill: "implement-conformance-testing-script", file: "run_conformance_tests", what: "conformance testing script" },
160
+ };
161
+ /** Raised before a build starts when the script that would decide it is done is missing. */
162
+ export class MissingTestScriptError extends Error {
163
+ // Declared fields, not parameter properties: node's own type stripping cannot run those.
164
+ moduleName;
165
+ kind;
166
+ constructor(moduleName, kind, note) {
167
+ const { skill, file, what } = SKILL[kind];
168
+ super([
169
+ `Stopped before building ${moduleName}: it has no ${what}, so nothing can decide when its ${kind === "unit" ? "unit" : "conformance"} tests pass.`,
170
+ ...(note !== undefined ? ["", note] : []),
171
+ "",
172
+ "What to change in the project:",
173
+ `- Add one with the \`${skill}\` skill: it writes \`${SCRIPTS_DIR}/${file}_<language>.sh\`.`,
174
+ `- It is looked for in \`${CONFIG_FILE}\` at the project root (\`${SCRIPT_KEYS[kind]}: <path>\`) - or the module's own`,
175
+ ` \`config.turri.<stack>.yaml\` beside it, the stack chosen with /stack or --stack, for a project of several stacks - then in the root,`,
176
+ ` then in \`${SCRIPTS_DIR}/\` - one ${file}*.sh in either folder is used.`,
177
+ "",
178
+ "Then build again.",
179
+ ].join("\n"));
180
+ this.name = "MissingTestScriptError";
181
+ this.moduleName = moduleName;
182
+ this.kind = kind;
183
+ }
184
+ }
185
+ /** The scripts a build needs, or the error that stops it before it starts. */
186
+ export function requireTestScripts(moduleName, scripts, needs) {
187
+ if (scripts.unit === undefined)
188
+ throw new MissingTestScriptError(moduleName, "unit", scripts.notes?.unit);
189
+ if (needs.conformance && scripts.conformance === undefined) {
190
+ throw new MissingTestScriptError(moduleName, "conformance", scripts.notes?.conformance);
191
+ }
192
+ }
193
+ /**
194
+ * The scripts that decide an agent's work, in its prompt - only those that will be run on it: a
195
+ * conformance testing script an agent's work is never judged by is left out. The whole script,
196
+ * read from the project as it is now.
197
+ */
198
+ export function withTestingScripts(prompt, scripts, kinds, root) {
199
+ const used = kinds.filter((kind) => scripts[kind] !== undefined);
200
+ if (used.length === 0)
201
+ return prompt;
202
+ const named = (path) => (path.startsWith(`${root}${sep}`) ? path.slice(root.length + 1) : basename(path));
203
+ const script = (kind, path) => `<script kind="${kind}" path="${named(path)}">\n${readFileSync(path, "utf8").trimEnd()}\n</script>`;
204
+ // The prepare script runs before the conformance testing script: shown with it, before it.
205
+ const prepare = used.includes("conformance") ? scripts.prepare : undefined;
206
+ const rendered = used.flatMap((kind) => [
207
+ ...(kind === "conformance" && prepare !== undefined ? [script("prepare environment", prepare)] : []),
208
+ script(kind === "unit" ? "unit tests" : "conformance tests", scripts[kind]),
209
+ ]);
210
+ const tools = used.map((kind) => (kind === "unit" ? "run_unit_tests" : "run_conformance_tests")).join(" and ");
211
+ const inPlace = (scripts.runOn ?? readProjectConfig(root).testingScriptsRunOn) !== "copy";
212
+ return [
213
+ "<testing scripts>",
214
+ rendered.join("\n\n"),
215
+ "</testing scripts>",
216
+ "",
217
+ `The harness decides whether your work is done by running ${used.length === 1 ? "this script" : "these scripts"} itself, ` +
218
+ (inPlace
219
+ ? "on this machine, in place, against the host codebase it merges your code into: "
220
+ : "on this machine, on a fresh copy of your work: ") +
221
+ used
222
+ .map((kind) => kind === "unit"
223
+ ? "the unit testing script as `<script> <build folder>`"
224
+ : `${prepare !== undefined ? "the prepare script as `<script> <build folder>` and, once it passes, " : ""}the conformance testing script as \`<script> <build folder> <whole conformance suite folder>\``)
225
+ .join(", ") +
226
+ ". Make the tests pass the way the script runs them.",
227
+ `A testing script may run static checks before its tests - the formatter in check mode, the linter, the type checker, ` +
228
+ `on what it tests: the code for the unit testing script, the tests for the conformance one. They count as the tests ` +
229
+ `do, and exit ${STATIC_CHECKS_FAILED} means the tests passed and only the checks did not. Formatting is yours: run the ` +
230
+ "formatter the script checks with on the files you change, in your shell, before the final run.",
231
+ `${used.length === 1 ? "It is" : "They are"} shown so you know what runs your tests - not to be run by you. ${used.length === 1 ? "It is" : "They are"} not in your ` +
232
+ `workspace, and the path above is the project's, not one you can reach. Never copy, recreate or adapt ${used.length === 1 ? "it" : "them"} in the ` +
233
+ `codebase, and never write a test runner of your own: call ${tools}, which run ${used.length === 1 ? "it" : "them"} exactly as the harness does - ` +
234
+ "that run is the one that counts. Run a single test or file with bash while you work, as you like.",
235
+ "They run in the person's own environment, with this machine's toolchains - the ones your shell has too. A toolchain " +
236
+ "or system library the script needs and this machine lacks is not yours to install: say what is missing, and the " +
237
+ "person installs it. What the code needs to be built and run belongs in the code (its dependency manifest), so " +
238
+ `${inPlace ? "it builds where the script puts it" : "a fresh copy of it builds"}.`,
239
+ "",
240
+ prompt,
241
+ ].join("\n");
242
+ }
243
+ /**
244
+ * Where one script run is staged - in the project's scratch, and new for every run: the scripts
245
+ * work in /tmp/<lang>_<basename of the build>, so the build is named for the module and this run
246
+ * too, and no two runs - two modules testing at once - share that folder.
247
+ */
248
+ function stagingFor(buildDir) {
249
+ const key = randomUUID().slice(0, 12);
250
+ const root = join(modulesScratchDir(buildDir), "scripts", key);
251
+ return { root, build: join(root, `${basename(dirname(resolve(buildDir)))}-${key}`), tests: join(root, "tests") };
252
+ }
253
+ /** The code, copied for a run; how many files. */
254
+ function stageBuild(buildDir, target) {
255
+ // There even when the build is empty: the scripts cd into it.
256
+ mkdirSync(target, { recursive: true });
257
+ const files = listFiles(buildDir);
258
+ for (const file of files) {
259
+ mkdirSync(dirname(join(target, file)), { recursive: true });
260
+ copyFileSync(join(buildDir, file), join(target, file));
261
+ }
262
+ return files.length;
263
+ }
264
+ /** Every functionality's tests in one folder. A name already taken is renamed, never overwritten. */
265
+ function stageTests(testsDirs, target) {
266
+ mkdirSync(target, { recursive: true });
267
+ const taken = new Set();
268
+ testsDirs.forEach((dir, index) => {
269
+ if (!existsSync(dir))
270
+ return;
271
+ for (const file of listFiles(dir)) {
272
+ let name = file;
273
+ if (taken.has(name)) {
274
+ const renamed = `${index + 1}_${basename(file)}`;
275
+ name = dirname(file) === "." ? renamed : join(dirname(file), renamed);
276
+ }
277
+ taken.add(name);
278
+ mkdirSync(dirname(join(target, name)), { recursive: true });
279
+ copyFileSync(join(dir, file), join(target, name));
280
+ }
281
+ });
282
+ return taken.size;
283
+ }
284
+ /** What the project calls a script: its path from the spec's folder when it is under it. */
285
+ function shown(script) {
286
+ const parent = dirname(dirname(script));
287
+ return script.startsWith(`${parent}/`) ? script.slice(parent.length + 1) : basename(script);
288
+ }
289
+ /** How long a test run may take: the command time limit. Over it, the run counts as failed. */
290
+ let scriptTimeoutMs = COMMAND_TIMEOUT_MS;
291
+ /** For tests: a shorter limit than two minutes. */
292
+ export function setScriptTimeoutForTests(ms) {
293
+ scriptTimeoutMs = ms ?? COMMAND_TIMEOUT_MS;
294
+ }
295
+ /**
296
+ * The project a build belongs to - the folder above its `plain_modules`, where the testing scripts'
297
+ * own paths start - and whether they run on the code in place (the default), rather than on a fresh copy
298
+ * (`testing-scripts-run-on: copy`).
299
+ */
300
+ function projectOfBuild(buildDir) {
301
+ const parts = resolve(buildDir).split(sep);
302
+ const index = parts.lastIndexOf("plain_modules");
303
+ const root = index > 0 ? parts.slice(0, index).join(sep) : dirname(resolve(buildDir));
304
+ // The stack chosen decides, when it says.
305
+ return { root, inPlace: readProjectConfig(root, stackOf(root)).testingScriptsRunOn !== "copy" };
306
+ }
307
+ /** The model providers' keys, kept out of what runs on this machine: the tests are agents' code. */
308
+ const PROVIDER_KEY = /^(ANTHROPIC|OPENAI|AZURE_OPENAI|GEMINI|GOOGLE|GOOGLE_GENERATIVE_AI|MISTRAL|GROQ|XAI|OPENROUTER|DEEPSEEK|CEREBRAS|TOGETHER|FIREWORKS)_API_KEY$|^AWS_BEARER_TOKEN_BEDROCK$/;
309
+ /** The environment a testing script runs in: the person's own, but for turri's model providers' keys. */
310
+ function scriptEnv() {
311
+ return Object.fromEntries(Object.entries(process.env).filter(([key]) => !PROVIDER_KEY.test(key)));
312
+ }
313
+ /**
314
+ * A testing script run on this machine - `bash <script> <args>` from `cwd`, through the host runner: a process group of
315
+ * its own, so the time limit and a stopped job end it and all it started. `named`: the path the script names itself by
316
+ * in its output, shown as the project's script.
317
+ */
318
+ async function runScript(cwd, script, args, logDir, options = {}) {
319
+ const timeoutMs = options.limitMs ?? scriptTimeoutMs;
320
+ const started = Date.now();
321
+ // Where and how it runs, for the log: how it ended is what the activity says.
322
+ reportLogNote(` ▶ ${shown(script)} on this machine, ${options.named !== undefined ? "on a fresh copy of the code" : `in place, from ${cwd}`} (limit ${Math.round(timeoutMs / 1000)}s)`);
323
+ const { exitCode, output, timedOut } = await runCommand(`bash ${[options.named ?? script, ...args].map(quote).join(" ")}`, {
324
+ cwd,
325
+ env: scriptEnv(),
326
+ timeoutMs,
327
+ timeoutNote: (ms) => `\n[stopped after ${Math.round(ms / 1000)}s, the time limit for a test run: counted as failed]`,
328
+ });
329
+ const took = ((Date.now() - started) / 1000).toFixed(1);
330
+ reportActivity(timedOut === true
331
+ ? ` ✗ ${shown(script)} did not finish in ${Math.round(timeoutMs / 1000)}s - stopped, counted as failed`
332
+ : ` ${exitCode === 0 ? "✓" : "✗"} ${shown(script)} exited ${exitCode} after ${took}s`);
333
+ // The copy's path, where the script names itself, reads as the project's script.
334
+ const text = options.named !== undefined ? output.split(options.named).join(shown(script)) : output;
335
+ const noted = withScriptLineNote(text, script);
336
+ // Its failures, counts and end are what the agent is shown; the whole is kept, a read_output away.
337
+ const log = keepTestOutput(logDir, basename(script), noted);
338
+ return { command: shown(script), exitCode: timedOut === true ? 124 : exitCode, output: shownTestOutput(noted, log), timedOut: timedOut === true, timeoutMs, ...(log !== undefined ? { log } : {}) };
339
+ }
340
+ /** What bash says when a line of the script itself cannot run: a command or file that is not there, or not runnable. */
341
+ const OWN_LINE_FAILURE = /: (No such file or directory|command not found|Permission denied)$/;
342
+ /** The script lines already told to the person, by script, line and error - once each, in this process. */
343
+ const toldScriptLines = new Set();
344
+ /**
345
+ * A run's output, with a note at its top when a line of the script itself fails where it runs - a macOS-only
346
+ * `/usr/libexec/java_home` in a Linux sandbox. The agent is told it is the person's script, not its code, so it does not
347
+ * go looking for what the line names; the person is told once, in the job's log.
348
+ */
349
+ function withScriptLineNote(output, script) {
350
+ const name = basename(script);
351
+ const prefix = new RegExp(`^(?:.*[/\\\\])?${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}: line (\\d+): (.+)$`);
352
+ const failures = output
353
+ .split("\n")
354
+ .map((line) => prefix.exec(line))
355
+ .filter((found) => found !== null && OWN_LINE_FAILURE.test(found[2]));
356
+ if (failures.length === 0)
357
+ return output;
358
+ const notes = failures.map(([, line, error]) => {
359
+ const key = `${script}\n${line}\n${error}`;
360
+ if (!toldScriptLines.has(key)) {
361
+ toldScriptLines.add(key);
362
+ reportNote(` ⚠ line ${line} of ${name} fails where it runs - \`${error}\`: the script is yours to fix; the agents are told it is not theirs`);
363
+ }
364
+ return (`[turri: line ${line} of the project's testing script, ${name}, fails where it runs - \`${error}\`. That is the person's ` +
365
+ "script, not your code: they have been told. Do not go looking for what it names; judge the run by what the tests " +
366
+ "themselves report.]");
367
+ });
368
+ return `${[...new Set(notes)].join("\n")}\n\n${output}`;
369
+ }
370
+ /** A path, quoted for bash. */
371
+ function quote(path) {
372
+ return `'${path.replace(/'/g, "'\\''")}'`;
373
+ }
374
+ /** A testing script run on a fresh copy of the code: the project's script copied fresh into the staging folder, beside it. */
375
+ async function runOnCopy(stagingRoot, script, args, logDir, limitMs) {
376
+ const copy = join(stagingRoot, basename(script));
377
+ writeFileSync(copy, readFileSync(script));
378
+ return runScript(stagingRoot, script, args, logDir, { named: copy, ...(limitMs !== undefined ? { limitMs } : {}) });
379
+ }
380
+ /**
381
+ * The harness's run of the unit testing script on the code as it is now - and run_unit_tests'.
382
+ *
383
+ * On this machine, on the code in place, or - `testing-scripts-run-on: copy` - on a fresh copy of the code
384
+ * (see the top of this file). This and runConformanceScript are the only ways the testing
385
+ * scripts are ever run.
386
+ */
387
+ export async function runUnitScript(buildDir, script) {
388
+ return runOnCode(buildDir, script, "unit testing script");
389
+ }
390
+ /** How long the last passing run of a script on a codebase took, by codebase and script. */
391
+ const lastPassing = new Map();
392
+ /** How long the last passing run of `script` on the code in `buildDir` took, in this process - undefined before one has. */
393
+ export function lastPassingMs(buildDir, script) {
394
+ return lastPassing.get(`${resolve(buildDir)}\n${script}`);
395
+ }
396
+ /**
397
+ * A script that takes the code folder - the unit testing script - run where the testing scripts
398
+ * run. `change`: what to do to the fresh copy before it runs - only where there is a copy; never in place.
399
+ */
400
+ export async function runOnCode(buildDir, script, what, change,
401
+ /** A time limit of its own, in place of a test run's. */
402
+ limitMs) {
403
+ const project = projectOfBuild(buildDir);
404
+ if (project.inPlace) {
405
+ if (change !== undefined)
406
+ throw new Error("This runs only where the testing scripts run on a copy: here they run in place, on the code itself.");
407
+ return runScript(project.root, script, [resolve(buildDir)], buildDir);
408
+ }
409
+ const staging = stagingFor(buildDir);
410
+ rmSync(staging.root, { recursive: true, force: true });
411
+ ensureInModulesScratch(buildDir, staging.root);
412
+ try {
413
+ reportActivity(` copying ${stageBuild(buildDir, staging.build)} files of the code for the ${what}`);
414
+ change?.(staging.build);
415
+ const started = Date.now();
416
+ const run = await runOnCopy(staging.root, script, [staging.build], buildDir, limitMs);
417
+ if (change === undefined && run.exitCode === 0)
418
+ lastPassing.set(`${resolve(buildDir)}\n${script}`, Date.now() - started);
419
+ return run;
420
+ }
421
+ finally {
422
+ rmSync(staging.root, { recursive: true, force: true });
423
+ }
424
+ }
425
+ /**
426
+ * The harness's run of the conformance testing script - and run_conformance_tests': the code, and
427
+ * the codebase's whole conformance suite - every functionality's tests, and those carried up from
428
+ * the modules below - in one folder, run at once. As runUnitScript runs: on a copy of the code staged beside the tests,
429
+ * or in place.
430
+ */
431
+ export async function runConformanceScript(buildDir, testsDirs, scripts) {
432
+ if (scripts.conformance === undefined)
433
+ throw new Error("No conformance testing script to run.");
434
+ const project = projectOfBuild(buildDir);
435
+ const staging = stagingFor(buildDir);
436
+ rmSync(staging.root, { recursive: true, force: true });
437
+ ensureInModulesScratch(buildDir, staging.root);
438
+ try {
439
+ let build;
440
+ let tests;
441
+ if (project.inPlace) {
442
+ // The real folders: the real tests folder when there is one; several merged into one, first.
443
+ build = resolve(buildDir);
444
+ if (testsDirs.length === 1)
445
+ tests = resolve(testsDirs[0]);
446
+ else {
447
+ stageTests(testsDirs, staging.tests);
448
+ tests = staging.tests;
449
+ }
450
+ }
451
+ else {
452
+ const code = stageBuild(buildDir, staging.build);
453
+ const count = stageTests(testsDirs, staging.tests);
454
+ reportActivity(` copying ${code} files of the code and the whole conformance suite (${count} files) for the conformance testing script`);
455
+ const prepared = scripts.prepare !== undefined ? await runOnCopy(staging.root, scripts.prepare, [staging.build], buildDir) : undefined;
456
+ if (prepared !== undefined && prepared.exitCode !== 0)
457
+ return preparationFailed(prepared, scripts.conformance);
458
+ return withPrepared(await runOnCopy(staging.root, scripts.conformance, [staging.build, staging.tests], buildDir), prepared);
459
+ }
460
+ // The environment first, on the same build the conformance tests get: the conformance testing script runs only once it passes.
461
+ const prepared = scripts.prepare !== undefined ? await runScript(project.root, scripts.prepare, [build], buildDir) : undefined;
462
+ if (prepared !== undefined && prepared.exitCode !== 0)
463
+ return preparationFailed(prepared, scripts.conformance);
464
+ return withPrepared(await runScript(project.root, scripts.conformance, [build, tests], buildDir), prepared);
465
+ }
466
+ finally {
467
+ rmSync(staging.root, { recursive: true, force: true });
468
+ }
469
+ }
470
+ /** A prepare script that failed: the run, said to be the preparation's - the conformance tests were not run. */
471
+ function preparationFailed(prepared, conformance) {
472
+ return {
473
+ ...prepared,
474
+ output: `${prepared.command} - the project's prepare script, run on the build before ${shown(conformance)} - exited ${prepared.exitCode}, so the conformance tests were not run.\n\n${prepared.output}`,
475
+ };
476
+ }
477
+ /** A conformance run, with a line at its top saying the prepare script ran first and passed. */
478
+ function withPrepared(run, prepared) {
479
+ return prepared === undefined ? run : { ...run, output: `${prepared.command} prepared the build first, and passed.\n\n${run.output}` };
480
+ }
481
+ /**
482
+ * `run_unit_tests` / `run_conformance_tests`: the agent's final check before it finishes - the
483
+ * project's testing script, run exactly as the harness will run it to decide whether the work is
484
+ * done. It takes no command. Everything else - one test, one file, another configuration, while
485
+ * the agent works - is what bash is for.
486
+ */
487
+ export function createScriptTool(options) {
488
+ const unit = options.kind === "unit";
489
+ const name = unit ? "run_unit_tests" : "run_conformance_tests";
490
+ const script = options.script !== undefined ? basename(options.script) : "the project's script";
491
+ return defineTool({
492
+ name,
493
+ label: unit ? "Run unit tests" : "Run conformance tests",
494
+ description: unit
495
+ ? `Final check before you finish: run the project's unit testing script, ${script}, ` +
496
+ "on the code as it is now - exactly as the harness runs it to decide whether this is done. It takes no command. To " +
497
+ "run one test, one file, or the tests any other way while you work, use bash."
498
+ : `Run the project's conformance testing script, ${script}, on a fresh copy of the code with the whole conformance ` +
499
+ "suite - exactly as the harness runs it to decide whether this is done. It takes no command. Your shell holds " +
500
+ "only the tests, not the code, so this is how you run them against the code: while you work, and as the final " +
501
+ "check before you finish.",
502
+ promptSnippet: `${name}: the final check - the project's ${unit ? "unit" : "conformance"} testing script, as the harness runs it`,
503
+ parameters: Type.Object({}),
504
+ async execute() {
505
+ const run = await options.run();
506
+ options.runs.push({ command: run.command, exitCode: run.exitCode });
507
+ options.onRun?.(run.command, run.exitCode);
508
+ const text = run.timedOut === true
509
+ ? timedOutFailure(run, unit ? "unit testing" : "conformance testing").replace("The harness ran", "You ran")
510
+ : `${run.command} exited ${run.exitCode}\n\n${run.output}`;
511
+ const loop = options.watch?.see(run);
512
+ return {
513
+ content: [{ type: "text", text: loop === undefined ? text : `${text}\n\n${loopNotice(loop)}` }],
514
+ details: { command: run.command, exitCode: run.exitCode, ...(loop !== undefined ? { loop } : {}) },
515
+ // Going round in circles a second time: its turn ends here, and the harness takes it from there.
516
+ ...(loop === "stop" ? { terminate: true } : {}),
517
+ };
518
+ },
519
+ });
520
+ }
521
+ /** How many of the agent's own runs failing the same way, one after another, are a loop. */
522
+ export const LOOP_RUNS = 6;
523
+ export function createLoopWatch(dir) {
524
+ /** The code folder at each failing run, as a digest of its files. */
525
+ const failedStates = new Set();
526
+ let strikes = 0;
527
+ let stuck = false;
528
+ let failing = 0;
529
+ let last;
530
+ let streak = 0;
531
+ const stateOf = () => [...digestFiles(dir)]
532
+ .filter(([file]) => file !== NOTES_FILE && !file.endsWith(`/${NOTES_FILE}`))
533
+ .sort(([a], [b]) => a.localeCompare(b))
534
+ .map(([file, digest]) => `${file} ${digest}`)
535
+ .join("\n");
536
+ return {
537
+ see(run) {
538
+ if (run.exitCode === 0 && run.timedOut !== true) {
539
+ failedStates.clear();
540
+ strikes = 0;
541
+ stuck = false;
542
+ failing = 0;
543
+ last = undefined;
544
+ streak = 0;
545
+ return undefined;
546
+ }
547
+ failing += 1;
548
+ const state = stateOf();
549
+ const signature = failureSignature(run);
550
+ streak = signature === last ? streak + 1 : 1;
551
+ last = signature;
552
+ const revisited = failedStates.has(state);
553
+ failedStates.add(state);
554
+ if (!revisited && streak < LOOP_RUNS)
555
+ return undefined;
556
+ streak = 0;
557
+ strikes += 1;
558
+ if (strikes === 1)
559
+ return "warn";
560
+ stuck = true;
561
+ return "stop";
562
+ },
563
+ get stuck() {
564
+ return stuck;
565
+ },
566
+ get failing() {
567
+ return failing;
568
+ },
569
+ reset() {
570
+ strikes = 0;
571
+ stuck = false;
572
+ streak = 0;
573
+ last = undefined;
574
+ },
575
+ };
576
+ }
577
+ /** What the agent is told with a run when it is going round in circles. */
578
+ function loopNotice(loop) {
579
+ if (loop === "warn") {
580
+ return [
581
+ "You are going round in circles: the code is back exactly as it was at an earlier failing run, or the tests have",
582
+ "failed the same way run after run. Another variation on what you have tried will not fix it. Stop editing and",
583
+ "find the cause first: read the code under test where the failure points - how it calls what the test replaces,",
584
+ "with which arguments - and the test, line by line, against it. Then make the one change that the cause calls for.",
585
+ ].join("\n");
586
+ }
587
+ return [
588
+ "You are still going round in circles, so your turn ends here. Change nothing more now: a debugger will look at",
589
+ "the failure with fresh eyes, and you will be given what it finds.",
590
+ ].join("\n");
591
+ }
592
+ /** A run the time limit stopped: failed, and what most often keeps a test run from ending. */
593
+ function timedOutFailure(run, what) {
594
+ const limit = Math.round(run.timeoutMs / 1000);
595
+ return [
596
+ `The harness ran the project's ${what} script, \`${run.command}\`, and it had not finished after ${limit} seconds - the`,
597
+ "time limit for a test run - so it was stopped, and counts as failed. This is what it had printed by then:",
598
+ "",
599
+ run.output.trimEnd(),
600
+ "",
601
+ "The output ends where it was stopped: the test running then, or the step after the last one it printed, is the one",
602
+ "that does not finish. Most often a test waits for something that never comes:",
603
+ "- a server or a watcher started in the foreground, instead of in the background and stopped afterwards;",
604
+ "- a prompt waiting for input, or a command that pages its output;",
605
+ "- a network call that never answers - a service on localhost that is not running, a host that is down;",
606
+ "- a lock, a queue or a thread nothing releases, or a loop that never ends.",
607
+ "Find it - run the tests on their own with bash, under `timeout`, a file or a case at a time - and fix what is wrong,",
608
+ `in the test or in the code, against the specification. If the tests are only slow, make them faster: every run has`,
609
+ `the same ${limit} seconds, and a skipped or deleted test does not count as a fix.`,
610
+ ].join("\n");
611
+ }
612
+ /**
613
+ * What a failed run says is not installed where it ran - the command, when its output names it - or undefined when
614
+ * the run failed for another reason. Exit 69 is the testing scripts' own way to say a toolchain is missing; exit 127,
615
+ * or a shell or `timeout` that could not find the command, says it as well.
616
+ */
617
+ export function missingToolchain(run) {
618
+ const named = /failed to run command [‘'`"]?([\w.+-]+)[’'`"]?: No such file or directory/.exec(run.output) ??
619
+ /(?:^|\n)(?:[^\n:]*: )?(?:line \d+: )?([\w.+-]+): command not found/.exec(run.output);
620
+ if (named !== null)
621
+ return { command: named[1] };
622
+ if (run.exitCode === 69 || run.exitCode === 127)
623
+ return {};
624
+ return undefined;
625
+ }
626
+ /** Where `command` is on this machine's PATH, or undefined when it is not there. */
627
+ export function onThisMachine(command) {
628
+ for (const dir of (process.env.PATH ?? "").split(delimiter)) {
629
+ if (dir === "")
630
+ continue;
631
+ const path = join(dir, command);
632
+ try {
633
+ accessSync(path, constants.X_OK);
634
+ return path;
635
+ }
636
+ catch {
637
+ /* not here */
638
+ }
639
+ }
640
+ return undefined;
641
+ }
642
+ /** What a failed script run means, for the agent that has to fix it. */
643
+ export function scriptFailure(run, what) {
644
+ if (run.timedOut === true)
645
+ return timedOutFailure(run, what);
646
+ const missing = missingToolchain(run);
647
+ const needs = missing?.command !== undefined ? `\`${missing.command}\`` : "the toolchain it needs";
648
+ const cause = missing !== undefined
649
+ ? `It could not run: ${needs} is not installed on this machine, where the testing scripts run. That is the person's ` +
650
+ "to install, not yours: do not work around it, and do not change the code or the tests for it. Call " +
651
+ "report_environment_error with exactly what is missing."
652
+ : [
653
+ ...fixMethod({
654
+ what: what === "unit testing" ? "unit tests" : "conformance tests",
655
+ fixes: what === "unit testing" ? "code" : "tests",
656
+ runTool: what === "unit testing" ? "run_unit_tests" : "run_conformance_tests",
657
+ }),
658
+ ].join("\n");
659
+ return [
660
+ `The harness ran the project's ${what} script, \`${run.command}\`, and it failed (exit ${run.exitCode}).`,
661
+ ...(missing === undefined && staticChecksOnly(run)
662
+ ? [
663
+ `Exit ${STATIC_CHECKS_FAILED}: the tests pass, and the script's static checks - the formatter, the linter, the type checker - ` +
664
+ "do not. Fix each finding at its cause; never silence one (a type-ignore or lint-disable comment, a rule " +
665
+ "turned off in the checker's configuration). A formatting finding goes by running the formatter.",
666
+ ]
667
+ : []),
668
+ "",
669
+ run.output.trimEnd(),
670
+ "",
671
+ cause,
672
+ ].join("\n");
673
+ }
674
+ /** How many rounds running the same failure, taken the same way, is nothing changing. */
675
+ export const STUCK_ROUNDS = 3;
676
+ /**
677
+ * A failing run, as a fingerprint: the lines that say what went wrong, without what changes from run
678
+ * to run - numbers, times, paths, ids. Two runs failing the same way have the same one.
679
+ */
680
+ export function failureSignature(run) {
681
+ if (run.timedOut === true)
682
+ return "the run did not finish in the time limit";
683
+ const lines = run.output.split("\n").map((line) => line.trim()).filter((line) => line !== "");
684
+ const telling = lines.filter((line) => /error|exception|fail|refused|timed out|cannot|unable|not found|denied/i.test(line));
685
+ const normal = (line) => line
686
+ // Colour codes, and ids a service makes up for each response - a request's UUID, a correlation id in hex.
687
+ .replace(/\x1B\[[0-9;]*m/g, "")
688
+ .replace(/\/[^\s:'"]+/g, "<path>")
689
+ .replace(/\b[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}\b/gi, "<id>")
690
+ .replace(/\b(?=[0-9a-f]*\d)(?=[0-9a-f]*[a-f])[0-9a-f]{4,}\b/gi, "<id>")
691
+ .replace(/\b[0-9a-f]{8,}\b/gi, "<id>")
692
+ .replace(/\d+(\.\d+)?/g, "<n>")
693
+ .replace(/\s+/g, " ");
694
+ return [...new Set((telling.length > 0 ? telling : lines.slice(-20)).map(normal))].slice(0, 30).join("\n");
695
+ }