@turri-dev/cli 0.0.0-stage → 0.1.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.
- package/.pi/themes/turri-dark.json +80 -0
- package/.pi/themes/turri-light.json +82 -0
- package/LICENSE +21 -0
- package/README.md +272 -2
- package/assets/jellyfish/dark-00.png +0 -0
- package/assets/jellyfish/dark-01.png +0 -0
- package/assets/jellyfish/dark-02.png +0 -0
- package/assets/jellyfish/dark-03.png +0 -0
- package/assets/jellyfish/dark-04.png +0 -0
- package/assets/jellyfish/dark-05.png +0 -0
- package/assets/jellyfish/dark-06.png +0 -0
- package/assets/jellyfish/dark-07.png +0 -0
- package/assets/jellyfish/dark-08.png +0 -0
- package/assets/jellyfish/dark-09.png +0 -0
- package/assets/jellyfish/dark-10.png +0 -0
- package/assets/jellyfish/dark-11.png +0 -0
- package/assets/jellyfish/dark-12.png +0 -0
- package/assets/jellyfish/dark-13.png +0 -0
- package/assets/jellyfish/dark-14.png +0 -0
- package/assets/jellyfish/fallback.txt +252 -0
- package/assets/jellyfish/light-00.png +0 -0
- package/assets/jellyfish/light-01.png +0 -0
- package/assets/jellyfish/light-02.png +0 -0
- package/assets/jellyfish/light-03.png +0 -0
- package/assets/jellyfish/light-04.png +0 -0
- package/assets/jellyfish/light-05.png +0 -0
- package/assets/jellyfish/light-06.png +0 -0
- package/assets/jellyfish/light-07.png +0 -0
- package/assets/jellyfish/light-08.png +0 -0
- package/assets/jellyfish/light-09.png +0 -0
- package/assets/jellyfish/light-10.png +0 -0
- package/assets/jellyfish/light-11.png +0 -0
- package/assets/jellyfish/light-12.png +0 -0
- package/assets/jellyfish/light-13.png +0 -0
- package/assets/jellyfish/light-14.png +0 -0
- package/bin/turri.mjs +29 -0
- package/dist/ask.js +149 -0
- package/dist/brand.js +50 -0
- package/dist/btw.js +174 -0
- package/dist/chat-models.js +37 -0
- package/dist/chat-skills.js +18 -0
- package/dist/cli.js +178 -0
- package/dist/config-command.js +197 -0
- package/dist/config-edit.js +367 -0
- package/dist/config.js +489 -0
- package/dist/conform-cli.js +29 -0
- package/dist/cultivate-cli.js +110 -0
- package/dist/drift-cli.js +25 -0
- package/dist/environment.js +70 -0
- package/dist/extension.js +2372 -0
- package/dist/genome.js +301 -0
- package/dist/graph.js +246 -0
- package/dist/identity.js +35 -0
- package/dist/init-config.js +511 -0
- package/dist/judge.js +62 -0
- package/dist/lineage-cli.js +13 -0
- package/dist/loop-cli.js +132 -0
- package/dist/loop-tools.js +225 -0
- package/dist/mature-tools.js +193 -0
- package/dist/model-check.js +117 -0
- package/dist/module-report.js +9 -0
- package/dist/pi-launch.js +31 -0
- package/dist/pi.js +813 -0
- package/dist/plain/check.js +76 -0
- package/dist/plain/concepts.js +213 -0
- package/dist/plain/errors.js +43 -0
- package/dist/plain/files.js +57 -0
- package/dist/plain/highlight.js +195 -0
- package/dist/plain/index.js +15 -0
- package/dist/plain/liquid.js +129 -0
- package/dist/plain/markdown.js +208 -0
- package/dist/plain/parser.js +752 -0
- package/dist/plain/plain.tmLanguage.json +124 -0
- package/dist/plain/spec.js +291 -0
- package/dist/plain/stdlib.js +44 -0
- package/dist/question-dialog.js +125 -0
- package/dist/questioning.js +109 -0
- package/dist/quiet.js +125 -0
- package/dist/regenerate/activity.js +187 -0
- package/dist/regenerate/agent-env.js +190 -0
- package/dist/regenerate/agent-shell.js +114 -0
- package/dist/regenerate/arbiter.js +133 -0
- package/dist/regenerate/audit.js +203 -0
- package/dist/regenerate/cases.js +83 -0
- package/dist/regenerate/change-view.js +102 -0
- package/dist/regenerate/choose-conformance.js +117 -0
- package/dist/regenerate/chunk-plan.js +625 -0
- package/dist/regenerate/codebase.js +75 -0
- package/dist/regenerate/command-check.js +312 -0
- package/dist/regenerate/compaction.js +20 -0
- package/dist/regenerate/confine.js +310 -0
- package/dist/regenerate/conform.js +133 -0
- package/dist/regenerate/conformance-agents.js +955 -0
- package/dist/regenerate/conformance.js +936 -0
- package/dist/regenerate/conversation-writes.js +100 -0
- package/dist/regenerate/credentials.js +88 -0
- package/dist/regenerate/debug-log.js +228 -0
- package/dist/regenerate/debugger.js +90 -0
- package/dist/regenerate/delivery.js +19 -0
- package/dist/regenerate/diagnose-job.js +40 -0
- package/dist/regenerate/diagnose.js +81 -0
- package/dist/regenerate/diff-view.js +240 -0
- package/dist/regenerate/drift.js +194 -0
- package/dist/regenerate/engine.js +118 -0
- package/dist/regenerate/environment-error.js +220 -0
- package/dist/regenerate/job-log.js +102 -0
- package/dist/regenerate/job-panel.js +336 -0
- package/dist/regenerate/jobs-tree.js +300 -0
- package/dist/regenerate/jobs.js +427 -0
- package/dist/regenerate/lanes.js +32 -0
- package/dist/regenerate/lineage.js +384 -0
- package/dist/regenerate/loop.js +1435 -0
- package/dist/regenerate/masking.js +95 -0
- package/dist/regenerate/notes.js +397 -0
- package/dist/regenerate/os-sandbox.js +243 -0
- package/dist/regenerate/paths.js +165 -0
- package/dist/regenerate/plain-palette.js +83 -0
- package/dist/regenerate/prompt.js +704 -0
- package/dist/regenerate/records.js +294 -0
- package/dist/regenerate/regenerate.js +598 -0
- package/dist/regenerate/regression.js +181 -0
- package/dist/regenerate/release.js +262 -0
- package/dist/regenerate/requires.js +241 -0
- package/dist/regenerate/resources.js +164 -0
- package/dist/regenerate/self-check.js +111 -0
- package/dist/regenerate/services.js +155 -0
- package/dist/regenerate/shell.js +319 -0
- package/dist/regenerate/side-panel.js +278 -0
- package/dist/regenerate/spec-map-view.js +574 -0
- package/dist/regenerate/spec-map.js +561 -0
- package/dist/regenerate/spec-steps.js +527 -0
- package/dist/regenerate/state.js +10 -0
- package/dist/regenerate/steer.js +91 -0
- package/dist/regenerate/steering.js +119 -0
- package/dist/regenerate/stop-report.js +161 -0
- package/dist/regenerate/submit-work.js +71 -0
- package/dist/regenerate/test-kinds.js +124 -0
- package/dist/regenerate/test-scripts.js +695 -0
- package/dist/regenerate/testing.js +406 -0
- package/dist/regenerate/transcript-view.js +1 -0
- package/dist/regenerate/unit-gate.js +20 -0
- package/dist/regenerate/unit-review.js +347 -0
- package/dist/regenerate/web.js +256 -0
- package/dist/regenerate/workspace.js +99 -0
- package/dist/regenerate-cli.js +68 -0
- package/dist/release-agent.js +114 -0
- package/dist/release-cli.js +131 -0
- package/dist/release-tools.js +148 -0
- package/dist/review/live.js +165 -0
- package/dist/rules.js +47 -0
- package/dist/self-invocation.js +110 -0
- package/dist/setup.js +245 -0
- package/dist/skill-progress.js +78 -0
- package/dist/skill.js +149 -0
- package/dist/skills.js +74 -0
- package/dist/spec-diff-cli.js +37 -0
- package/dist/spec-tools.js +155 -0
- package/dist/stack.js +158 -0
- package/dist/stacks.js +97 -0
- package/dist/stdlib.js +100 -0
- package/dist/terminal-images.js +46 -0
- package/dist/tools.js +242 -0
- package/dist/validate-cli.js +52 -0
- package/docs/README.md +58 -0
- package/docs/authoring-plain.md +245 -0
- package/docs/config.md +353 -0
- package/docs/deploy.md +109 -0
- package/docs/development.md +75 -0
- package/docs/headless.md +148 -0
- package/docs/images/how-turri-works.svg +50 -0
- package/docs/images/turri-banner.png +0 -0
- package/docs/installation.md +118 -0
- package/docs/parser.md +108 -0
- package/docs/regenerating.md +1116 -0
- package/docs/sandbox.md +233 -0
- package/docs/skills-and-gates.md +254 -0
- package/docs/theming.md +76 -0
- package/docs/using.md +454 -0
- package/genome/rules/bullet-continuation.md +69 -0
- package/genome/rules/concise-specs-examples.md +431 -0
- package/genome/rules/concise-specs-integration-examples.md +270 -0
- package/genome/rules/concise-specs.md +80 -0
- package/genome/rules/definitions.md +107 -0
- package/genome/rules/examples/integration-embedded-testing/run_conformance_tests_java.sh +163 -0
- package/genome/rules/examples/integration-embedded-testing/run_unittests_java.sh +51 -0
- package/genome/rules/exported-concepts.md +39 -0
- package/genome/rules/func-specs.md +129 -0
- package/genome/rules/impl-reqs.md +68 -0
- package/genome/rules/import-modules.md +48 -0
- package/genome/rules/integration-embedded-testing.md +276 -0
- package/genome/rules/integration-embedded.md +171 -0
- package/genome/rules/integration-standalone.md +135 -0
- package/genome/rules/integrations.md +137 -0
- package/genome/rules/linked-resources.md +69 -0
- package/genome/rules/module-structure.md +105 -0
- package/genome/rules/project-model.md +88 -0
- package/genome/rules/required-concepts.md +44 -0
- package/genome/rules/requires-modules.md +80 -0
- package/genome/rules/stdlib.md +73 -0
- package/genome/rules/test-reqs.md +57 -0
- package/genome/skills/add-acceptance-test/SKILL.md +100 -0
- package/genome/skills/add-concept/SKILL.md +71 -0
- package/genome/skills/add-functional-spec/SKILL.md +84 -0
- package/genome/skills/add-functional-specs/SKILL.md +118 -0
- package/genome/skills/add-implementation-requirement/SKILL.md +86 -0
- package/genome/skills/add-resource/SKILL.md +105 -0
- package/genome/skills/add-template/SKILL.md +64 -0
- package/genome/skills/add-test-requirement/SKILL.md +73 -0
- package/genome/skills/analyze-2-func-specs/SKILL.md +101 -0
- package/genome/skills/analyze-func-specs/SKILL.md +123 -0
- package/genome/skills/analyze-if-func-spec-too-complex/SKILL.md +211 -0
- package/genome/skills/break-down-func-spec/SKILL.md +205 -0
- package/genome/skills/brood/SKILL.md +229 -0
- package/genome/skills/brood/references/intent.md +35 -0
- package/genome/skills/brood/references/product.md +101 -0
- package/genome/skills/brood/references/stack.md +61 -0
- package/genome/skills/brood/references/testing.md +56 -0
- package/genome/skills/bud/SKILL.md +148 -0
- package/genome/skills/bud/references/checklist.md +44 -0
- package/genome/skills/bud/references/intent.md +30 -0
- package/genome/skills/check-plain-env/SKILL.md +285 -0
- package/genome/skills/consolidate-concepts/SKILL.md +192 -0
- package/genome/skills/create-import-module/SKILL.md +104 -0
- package/genome/skills/create-requires-module/SKILL.md +109 -0
- package/genome/skills/debug-specs/SKILL.md +188 -0
- package/genome/skills/implement-conformance-testing-script/SKILL.md +238 -0
- package/genome/skills/implement-conformance-testing-script/assets/run_conformance_tests_cypress.ps1 +337 -0
- package/genome/skills/implement-conformance-testing-script/assets/run_conformance_tests_golang.ps1 +111 -0
- package/genome/skills/implement-conformance-testing-script/assets/run_conformance_tests_java.sh +136 -0
- package/genome/skills/implement-conformance-testing-script/assets/run_conformance_tests_python.ps1 +103 -0
- package/genome/skills/implement-conformance-testing-script/assets/run_conformance_tests_python.sh +150 -0
- package/genome/skills/implement-unit-testing-script/SKILL.md +160 -0
- package/genome/skills/implement-unit-testing-script/assets/run_unittests_flutter.ps1 +87 -0
- package/genome/skills/implement-unit-testing-script/assets/run_unittests_golang.ps1 +73 -0
- package/genome/skills/implement-unit-testing-script/assets/run_unittests_java.sh +72 -0
- package/genome/skills/implement-unit-testing-script/assets/run_unittests_python.ps1 +81 -0
- package/genome/skills/implement-unit-testing-script/assets/run_unittests_python.sh +98 -0
- package/genome/skills/implement-unit-testing-script/assets/run_unittests_react.ps1 +87 -0
- package/genome/skills/init-plain-project/SKILL.md +117 -0
- package/genome/skills/init-plain-project/references/checklist.md +30 -0
- package/genome/skills/mature/SKILL.md +126 -0
- package/genome/skills/refactor-module/SKILL.md +196 -0
- package/genome/skills/resolve-section-ownership/SKILL.md +64 -0
- package/genome/skills/resolve-spec-conflict/SKILL.md +96 -0
- package/genome/skills/spawn/SKILL.md +163 -0
- package/package.json +68 -4
- package/stdlib/backend/common.plain +25 -0
- package/stdlib/backend/conformance/common.plain +12 -0
- package/stdlib/backend/conformance/go-gin.plain +9 -0
- package/stdlib/backend/conformance/go-nethttp.plain +9 -0
- package/stdlib/backend/conformance/java-springboot.plain +9 -0
- package/stdlib/backend/conformance/python-django.plain +9 -0
- package/stdlib/backend/conformance/python-fastapi.plain +9 -0
- package/stdlib/backend/conformance/python-flask.plain +9 -0
- package/stdlib/backend/conformance/rust-axum.plain +9 -0
- package/stdlib/backend/conformance/typescript-express.plain +9 -0
- package/stdlib/backend/conformance/typescript-fastify.plain +9 -0
- package/stdlib/backend/conformance/typescript-nestjs.plain +9 -0
- package/stdlib/backend/go-gin.plain +18 -0
- package/stdlib/backend/go-nethttp.plain +18 -0
- package/stdlib/backend/java-springboot.plain +19 -0
- package/stdlib/backend/python-django.plain +19 -0
- package/stdlib/backend/python-fastapi.plain +23 -0
- package/stdlib/backend/python-flask.plain +23 -0
- package/stdlib/backend/rust-axum.plain +17 -0
- package/stdlib/backend/typescript-express.plain +19 -0
- package/stdlib/backend/typescript-fastify.plain +19 -0
- package/stdlib/backend/typescript-nestjs.plain +19 -0
- package/stdlib/cli/common.plain +17 -0
- package/stdlib/cli/conformance/common.plain +11 -0
- package/stdlib/cli/conformance/go-cobra.plain +9 -0
- package/stdlib/cli/conformance/go-flag.plain +9 -0
- package/stdlib/cli/conformance/java-picocli.plain +9 -0
- package/stdlib/cli/conformance/python-argparse.plain +9 -0
- package/stdlib/cli/conformance/python-click.plain +9 -0
- package/stdlib/cli/conformance/python-typer.plain +9 -0
- package/stdlib/cli/conformance/rust-clap.plain +9 -0
- package/stdlib/cli/conformance/typescript-commander.plain +9 -0
- package/stdlib/cli/go-cobra.plain +14 -0
- package/stdlib/cli/go-flag.plain +14 -0
- package/stdlib/cli/java-picocli.plain +15 -0
- package/stdlib/cli/python-argparse.plain +18 -0
- package/stdlib/cli/python-click.plain +16 -0
- package/stdlib/cli/python-typer.plain +16 -0
- package/stdlib/cli/rust-clap.plain +14 -0
- package/stdlib/cli/typescript-commander.plain +17 -0
- package/stdlib/frontend/angular.plain +17 -0
- package/stdlib/frontend/common.plain +17 -0
- package/stdlib/frontend/conformance/angular.plain +5 -0
- package/stdlib/frontend/conformance/common.plain +11 -0
- package/stdlib/frontend/conformance/react-vite.plain +5 -0
- package/stdlib/frontend/conformance/sveltekit-spa.plain +5 -0
- package/stdlib/frontend/conformance/vue-vite.plain +5 -0
- package/stdlib/frontend/react-vite.plain +16 -0
- package/stdlib/frontend/sveltekit-spa.plain +16 -0
- package/stdlib/frontend/vue-vite.plain +16 -0
- package/stdlib/lang/go.plain +13 -0
- package/stdlib/lang/java.plain +12 -0
- package/stdlib/lang/python.plain +18 -0
- package/stdlib/lang/rust.plain +12 -0
- package/stdlib/lang/typescript.plain +13 -0
- package/stdlib/library/common.plain +15 -0
- package/stdlib/library/conformance/common.plain +9 -0
- package/stdlib/library/conformance/go.plain +9 -0
- package/stdlib/library/conformance/java-gradle.plain +9 -0
- package/stdlib/library/conformance/java-maven.plain +9 -0
- package/stdlib/library/conformance/python.plain +9 -0
- package/stdlib/library/conformance/rust.plain +9 -0
- package/stdlib/library/conformance/typescript.plain +9 -0
- package/stdlib/library/go.plain +12 -0
- package/stdlib/library/java-gradle.plain +13 -0
- package/stdlib/library/java-maven.plain +13 -0
- package/stdlib/library/python.plain +13 -0
- package/stdlib/library/rust.plain +12 -0
- package/stdlib/library/typescript.plain +15 -0
|
@@ -0,0 +1,1435 @@
|
|
|
1
|
+
import { existsSync, readFileSync, rmSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
3
|
+
import { plainFileParser, withParseCache } from "../plain/parser.js";
|
|
4
|
+
import { STDLIB_ROOT } from "../plain/stdlib.js";
|
|
5
|
+
import { ACCEPTANCE_TESTS, FUNCTIONAL_REQUIREMENTS, REQUIRED_FUNCTIONAL_SPECS, TEST_REQUIREMENTS } from "../plain/spec.js";
|
|
6
|
+
import { reportActivity, reportStage } from "./activity.js";
|
|
7
|
+
import { llmChunkAgents, MAX_LINES_PER_JOB, MAX_SPECS_PER_JOB, planChunks, stepsOf } from "./chunk-plan.js";
|
|
8
|
+
import { commitNotes, recordHistory, resetToLastCommit } from "./lineage.js";
|
|
9
|
+
import { NOTES_FILE, recordPulse } from "./notes.js";
|
|
10
|
+
import { createJobRegistry, formatDuration } from "./jobs.js";
|
|
11
|
+
import { moduleLanes } from "./lanes.js";
|
|
12
|
+
import { adoptDigests, chainTree, codeTree, conformanceTarget, REGENERATE_AGAIN, inheritedRecord, notBuilt, readRecord, RECORD_FILE, withResourceDigests, writeCodeRecord, writeTestsRecord, } from "./records.js";
|
|
13
|
+
import { describeSpecDiffConformance, renderSpecDiffConformance } from "./conform.js";
|
|
14
|
+
import { conformanceDirFor } from "./conformance.js";
|
|
15
|
+
import { modulesRoot } from "./paths.js";
|
|
16
|
+
import { budgetFor, choosingWhen, configForSpec, conformanceSwitchedOn } from "../config.js";
|
|
17
|
+
import { describeSpecChange } from "./prompt.js";
|
|
18
|
+
import { resolveTemplateDirs } from "./paths.js";
|
|
19
|
+
import { builtTips, copyRequiredCode, newlyRequired, resolveRequiresChain, startFromScratch } from "./requires.js";
|
|
20
|
+
import { withTryItLine } from "./delivery.js";
|
|
21
|
+
import { describeSpecDiff, renderSpecDiff, specFilesOf } from "./regenerate.js";
|
|
22
|
+
import { locateDiffLines } from "./diff-view.js";
|
|
23
|
+
import { agentChanges, changeDiffInFiles } from "./change-view.js";
|
|
24
|
+
import { createSteering, MAX_STEERS, markTold, steer, waitingSteer } from "./steering.js";
|
|
25
|
+
import { projectDirOf } from "./paths.js";
|
|
26
|
+
import { changesBetween, parseTree, serializeTree } from "./spec-steps.js";
|
|
27
|
+
import { listFiles } from "./testing.js";
|
|
28
|
+
import { casesOf, codeWork, stillWanted, whyNotWanted } from "./drift.js";
|
|
29
|
+
/** What is left to regenerate, as the conversation and the command line read it. */
|
|
30
|
+
export function describeDrift(reports) {
|
|
31
|
+
if (reports.length === 0)
|
|
32
|
+
return "Nothing to look at: no module of that spec.";
|
|
33
|
+
const cases = (work) => casesText(work);
|
|
34
|
+
// Fully regenerated: in every codebase the code - and its conformance tests, where there are any - regenerated from
|
|
35
|
+
// the spec as it is, nothing regenerating or waiting, and nothing holding the codebase back. A pulse that did not
|
|
36
|
+
// land leaves its work undone, so it shows as drift.
|
|
37
|
+
// conformance: null is no drift, undefined none written here - either is regenerated; a case list is work left.
|
|
38
|
+
const regenerated = reports.every((report) => report.code === null && (report.conformance === null || report.conformance === undefined));
|
|
39
|
+
const settled = reports.every((report) => report.running === undefined && report.why === undefined && report.startOver === undefined && report.scheduled.length === 0);
|
|
40
|
+
const verdict = regenerated && settled ? "\nFully regenerated: every codebase is regenerated from the spec as it is, and nothing is regenerating." : "";
|
|
41
|
+
return reports
|
|
42
|
+
.map((report) => {
|
|
43
|
+
const lines = [`${report.module} (${basename(report.plainFile)}):`];
|
|
44
|
+
if (report.code === null)
|
|
45
|
+
lines.push(" code: regenerated from the spec as it is - no drift");
|
|
46
|
+
else if (report.code !== undefined)
|
|
47
|
+
lines.push(` code: drifted from the spec - to regenerate: ${cases(report.code)}`);
|
|
48
|
+
if (report.conformance === null)
|
|
49
|
+
lines.push(" conformance tests: written for what is built - no drift");
|
|
50
|
+
else if (report.conformance !== undefined)
|
|
51
|
+
lines.push(` conformance tests: to write or change - ${cases(report.conformance)}`);
|
|
52
|
+
else
|
|
53
|
+
lines.push(" conformance tests: none written here");
|
|
54
|
+
for (const group of report.scheduled)
|
|
55
|
+
lines.push(` conformance tests ahead of their code: fs ${group.frids.join(", fs ")} - once ${group.unbuilt.map((frid) => `fs ${frid}`).join(", ")} ${group.unbuilt.length === 1 ? "is" : "are"} regenerated`);
|
|
56
|
+
if (report.running !== undefined)
|
|
57
|
+
lines.push(` regenerating now: pulse ${report.running.jobId} - ${report.running.title}`);
|
|
58
|
+
if (report.why !== undefined)
|
|
59
|
+
lines.push(` ${report.why}`);
|
|
60
|
+
if (report.startOver !== undefined)
|
|
61
|
+
lines.push(` it has to be regenerated from scratch on ${report.startOver.requires.join(", ")} before anything more is regenerated there`);
|
|
62
|
+
return lines.join("\n");
|
|
63
|
+
})
|
|
64
|
+
.join("\n") + verdict;
|
|
65
|
+
}
|
|
66
|
+
const tipOf = (chain) => chain.at(-1);
|
|
67
|
+
/** Whether a module's codebase has been started: code in it, or a record. */
|
|
68
|
+
const started = (entry) => listFiles(entry.buildDir).length > 0 || readRecord(entry.buildDir) !== null;
|
|
69
|
+
/** The spec a codebase is built from, as the .plain files have it now: its whole requires chain's. */
|
|
70
|
+
export function chainSpec(chain) {
|
|
71
|
+
// Each module's linked resources hashed where its links point: what a file holds is part of the spec.
|
|
72
|
+
const trees = chain.map((entry) => {
|
|
73
|
+
const dirs = resolveTemplateDirs(entry.plainFile);
|
|
74
|
+
return { module: entry.moduleName, tree: withResourceDigests(plainFileParser(basename(entry.plainFile), dirs, { allowNoFunctionalities: true })[1], dirs) };
|
|
75
|
+
});
|
|
76
|
+
const tip = tipOf(chain);
|
|
77
|
+
return { moduleName: tip.moduleName, plainFile: tip.plainFile, tree: chainTree(trees), chain };
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* The chains a request for `plainFile` brings up: where its module's code lives - every built tip whose
|
|
81
|
+
* chain holds it, one per branch - or, with none, its own chain. Each from its furthest-built module on:
|
|
82
|
+
* the codebases below that are left as they are.
|
|
83
|
+
*/
|
|
84
|
+
function chainsFor(plainFile) {
|
|
85
|
+
const tips = builtTips(plainFile);
|
|
86
|
+
return tips.length > 0 ? tips : [resolveRequiresChain(plainFile, [], undefined, { allowNoFunctionalities: true })];
|
|
87
|
+
}
|
|
88
|
+
/** How many functional specs of its own a codebase has built - none when it was only started from the code below it. */
|
|
89
|
+
function ownBuilt(entry) {
|
|
90
|
+
return (readRecord(entry.buildDir)?.[FUNCTIONAL_REQUIREMENTS] ?? []).length;
|
|
91
|
+
}
|
|
92
|
+
/** The modules of a chain to build: its furthest-built one, and those above it - or, none built, all of it. */
|
|
93
|
+
function toBuild(chain) {
|
|
94
|
+
let from = 0;
|
|
95
|
+
chain.forEach((entry, index) => {
|
|
96
|
+
if (started(entry))
|
|
97
|
+
from = index;
|
|
98
|
+
});
|
|
99
|
+
return chain.slice(from);
|
|
100
|
+
}
|
|
101
|
+
export function createWorkLoop(options) {
|
|
102
|
+
const modules = new Map();
|
|
103
|
+
const order = [];
|
|
104
|
+
const run = options.run ?? defaultRunner;
|
|
105
|
+
const runConformance = options.runConformance ?? defaultConformanceRunner;
|
|
106
|
+
// Off unless the config turns it on: all (true), or chosen.
|
|
107
|
+
const configConformance = options.conformanceOn ?? ((plainFile) => conformanceSwitchedOn(configForSpec(plainFile).conformanceTests));
|
|
108
|
+
/** When the chooser decides: after each code pulse (chosen), once the module is regenerated (module), or never. */
|
|
109
|
+
const chooseWhen = (plainFile) => options.conformanceChoosing !== undefined
|
|
110
|
+
? options.conformanceChoosing(plainFile)
|
|
111
|
+
: options.conformanceChosen !== undefined
|
|
112
|
+
? options.conformanceChosen(plainFile)
|
|
113
|
+
? "pulse"
|
|
114
|
+
: undefined
|
|
115
|
+
: choosingWhen(configForSpec(plainFile).conformanceTests);
|
|
116
|
+
const configChosen = (plainFile) => chooseWhen(plainFile) !== undefined;
|
|
117
|
+
// One tick at a time: each reads the records and the spec, and decides.
|
|
118
|
+
let ticking = Promise.resolve();
|
|
119
|
+
const tipEntry = (state) => tipOf(state.chain);
|
|
120
|
+
const keyOf = (spec) => serializeTree(codeTree(spec.tree));
|
|
121
|
+
/** What a codebase is built from: its record, or - nothing built - what it starts from, the code of the module it requires. */
|
|
122
|
+
const builtOf = (state) => {
|
|
123
|
+
const tip = tipEntry(state);
|
|
124
|
+
const record = readRecord(tip.buildDir);
|
|
125
|
+
if (record !== null)
|
|
126
|
+
return codeTree(record);
|
|
127
|
+
const below = state.chain.find((entry) => entry.moduleName === tip.requires);
|
|
128
|
+
const source = below === undefined ? null : readRecord(below.buildDir);
|
|
129
|
+
return source === null ? {} : codeTree(inheritedRecord(source, below.moduleName));
|
|
130
|
+
};
|
|
131
|
+
/** Where a codebase's conformance tests are: the module's tests folder. */
|
|
132
|
+
const testsDirOf = (state) => {
|
|
133
|
+
const tip = tipEntry(state);
|
|
134
|
+
return conformanceDirFor(tip.moduleName, modulesRoot(projectDirOf(tip.plainFile)));
|
|
135
|
+
};
|
|
136
|
+
/** What the conformance tests are written for, by their record - nothing, when there is none. */
|
|
137
|
+
const testedOf = (state) => readRecord(testsDirOf(state)) ?? {};
|
|
138
|
+
/** What the code is built from, as a key: a conformance plan made against other code is made again. */
|
|
139
|
+
const codeKeyOf = (state) => serializeTree(readRecord(tipEntry(state).buildDir) ?? {});
|
|
140
|
+
/**
|
|
141
|
+
* What the conformance tests are to be written for now: of what the code is built from, what is chosen, with the test
|
|
142
|
+
* reqs as the spec has them. `from`: the tests the work starts from - what a running pulse will land, when the work
|
|
143
|
+
* after it is planned ahead - else the tests record as it is. Read from the record alone, a pulse still writing a
|
|
144
|
+
* functional spec's tests would not count them: one whose code is behind the spec would be planned out, its tests
|
|
145
|
+
* taken out again once they landed.
|
|
146
|
+
*/
|
|
147
|
+
const conformanceOf = (state, spec, from) => {
|
|
148
|
+
const tests = from ?? readRecord(testsDirOf(state));
|
|
149
|
+
const target = conformanceTarget(readRecord(tipEntry(state).buildDir), tests, spec.tree, chosenOf(state, spec, tests));
|
|
150
|
+
// No functional spec to test, and none tested: nothing to write - the reqs alone are no conformance job.
|
|
151
|
+
const specsIn = (tree) => (tree?.[FUNCTIONAL_REQUIREMENTS] ?? []).length;
|
|
152
|
+
return specsIn(target) === 0 && specsIn(tests) === 0 ? (tests ?? {}) : target;
|
|
153
|
+
};
|
|
154
|
+
/**
|
|
155
|
+
* Which functional specs the conformance tests are for: every one - unless the config has them for the chosen ones,
|
|
156
|
+
* or the conversation named some over a config that has them off. Then: those named, those with acceptance tests
|
|
157
|
+
* when the config chooses, and those tested already - a choice is kept, and tests are never taken out because
|
|
158
|
+
* other functional specs were named.
|
|
159
|
+
*/
|
|
160
|
+
const chosenOf = (state, spec, tests) => {
|
|
161
|
+
const named = state.conformance?.chosen;
|
|
162
|
+
const choosing = configChosen(tipEntry(state).plainFile);
|
|
163
|
+
if (!choosing && (state.conformance === undefined || named === undefined || configConformance(tipEntry(state).plainFile)))
|
|
164
|
+
return undefined;
|
|
165
|
+
if (choosing && state.conformance !== undefined && named === undefined)
|
|
166
|
+
return undefined;
|
|
167
|
+
const tested = new Set((tests?.[FUNCTIONAL_REQUIREMENTS] ?? []).map((item) => item.frid));
|
|
168
|
+
const specs = spec.tree[FUNCTIONAL_REQUIREMENTS] ?? [];
|
|
169
|
+
const accepted = (frid) => (specs[Number(frid) - 1]?.[ACCEPTANCE_TESTS] ?? []).length > 0;
|
|
170
|
+
return (frid) => tested.has(frid) || named?.has(frid) === true || (choosing && accepted(frid));
|
|
171
|
+
};
|
|
172
|
+
/**
|
|
173
|
+
* Under `conformance-tests: chosen`: the functional specs a code job built or changed that no conformance test is
|
|
174
|
+
* for or is to be for - for the conversation to choose from.
|
|
175
|
+
*/
|
|
176
|
+
const toChoose = (state, planned, spec) => {
|
|
177
|
+
if (planned.track !== "code" || !configChosen(tipEntry(state).plainFile))
|
|
178
|
+
return [];
|
|
179
|
+
const chosen = chosenOf(state, spec, readRecord(testsDirOf(state)));
|
|
180
|
+
if (chosen === undefined)
|
|
181
|
+
return [];
|
|
182
|
+
const scheduled = new Set((state.scheduled ?? []).flatMap((group) => group.specs.map((touched) => touched.frid)));
|
|
183
|
+
return [...planned.cases.add, ...planned.cases.patch].filter((touched) => !chosen(touched.frid) && !scheduled.has(touched.frid));
|
|
184
|
+
};
|
|
185
|
+
/**
|
|
186
|
+
* Under `conformance-budget:`: how many more of the module's functional specs may be chosen - the budget less every
|
|
187
|
+
* one chosen for it already, tested or scheduled, whenever that was; those with acceptance tests do not count. Undefined:
|
|
188
|
+
* no budget.
|
|
189
|
+
*/
|
|
190
|
+
const budgetLeft = (state, spec) => {
|
|
191
|
+
const budget = configForSpec(tipEntry(state).plainFile).conformanceBudget;
|
|
192
|
+
if (budget === undefined)
|
|
193
|
+
return undefined;
|
|
194
|
+
const items = spec.tree[FUNCTIONAL_REQUIREMENTS] ?? [];
|
|
195
|
+
const tests = readRecord(testsDirOf(state));
|
|
196
|
+
const tested = new Set((tests?.[FUNCTIONAL_REQUIREMENTS] ?? []).map((item) => item.frid));
|
|
197
|
+
const named = state.conformance?.chosen ?? new Set();
|
|
198
|
+
const scheduled = new Set((state.scheduled ?? []).flatMap((group) => group.specs.map((touched) => touched.frid)));
|
|
199
|
+
const used = items.filter((item, index) => {
|
|
200
|
+
const frid = String(index + 1);
|
|
201
|
+
return (item[ACCEPTANCE_TESTS] ?? []).length === 0 && (tested.has(frid) || named.has(frid) || scheduled.has(frid));
|
|
202
|
+
}).length;
|
|
203
|
+
return Math.max(0, budgetFor(budget, items.length) - used);
|
|
204
|
+
};
|
|
205
|
+
/**
|
|
206
|
+
* Under `conformance-tests: chosen`: every functional spec of the module that is built and that no conformance test
|
|
207
|
+
* is for, chosen or scheduled - the module's whole choice, once its code is built.
|
|
208
|
+
*/
|
|
209
|
+
const moduleToChoose = (state, spec) => {
|
|
210
|
+
if (!configChosen(tipEntry(state).plainFile))
|
|
211
|
+
return [];
|
|
212
|
+
const chosen = chosenOf(state, spec, readRecord(testsDirOf(state)));
|
|
213
|
+
if (chosen === undefined)
|
|
214
|
+
return [];
|
|
215
|
+
const scheduled = new Set((state.scheduled ?? []).flatMap((group) => group.specs.map((touched) => touched.frid)));
|
|
216
|
+
const items = spec.tree[FUNCTIONAL_REQUIREMENTS] ?? [];
|
|
217
|
+
const frids = items.map((_, index) => String(index + 1));
|
|
218
|
+
const unbuilt = new Set(notBuilt(readRecord(tipEntry(state).buildDir), spec.tree, frids));
|
|
219
|
+
return frids
|
|
220
|
+
.filter((frid) => !unbuilt.has(frid) && !chosen(frid) && !scheduled.has(frid))
|
|
221
|
+
.map((frid) => ({ frid, text: items[Number(frid) - 1].markdown.trim().split("\n")[0].replace(/^-\s*/, "").trim() }));
|
|
222
|
+
};
|
|
223
|
+
/**
|
|
224
|
+
* The groups scheduled whose functional specs are all built, as the spec has them: chosen, so their tests are
|
|
225
|
+
* written. A functional spec taken out of the spec is dropped from its group.
|
|
226
|
+
*/
|
|
227
|
+
const promote = (state, spec) => {
|
|
228
|
+
const chosen = state.conformance?.chosen;
|
|
229
|
+
if (state.scheduled === undefined || chosen === undefined)
|
|
230
|
+
return;
|
|
231
|
+
const count = (spec.tree[FUNCTIONAL_REQUIREMENTS] ?? []).length;
|
|
232
|
+
const code = readRecord(tipEntry(state).buildDir);
|
|
233
|
+
state.scheduled = state.scheduled.flatMap((group) => {
|
|
234
|
+
const specs = group.specs.filter((touched) => Number(touched.frid) <= count);
|
|
235
|
+
const unbuilt = notBuilt(code, spec.tree, specs.map((touched) => touched.frid));
|
|
236
|
+
if (specs.length === 0)
|
|
237
|
+
return [];
|
|
238
|
+
if (unbuilt.length > 0)
|
|
239
|
+
return [{ specs, unbuilt }];
|
|
240
|
+
for (const touched of specs)
|
|
241
|
+
chosen.add(touched.frid);
|
|
242
|
+
state.together = [...(state.together ?? []), specs];
|
|
243
|
+
// What the tests are to be written for changed: planned again - after the running job, when one runs.
|
|
244
|
+
state.conformancePlan = undefined;
|
|
245
|
+
return [];
|
|
246
|
+
});
|
|
247
|
+
};
|
|
248
|
+
/** Whether a codebase's conformance tests are written: asked for, or on in the config - with test reqs to write them from, and code to test. */
|
|
249
|
+
const conformanceWanted = (state, spec) => (state.conformance !== undefined || configConformance(tipEntry(state).plainFile)) &&
|
|
250
|
+
(spec.tree[TEST_REQUIREMENTS] ?? []).length > 0 &&
|
|
251
|
+
readRecord(tipEntry(state).buildDir) !== null;
|
|
252
|
+
/**
|
|
253
|
+
* The functional specs a conformance job is scheduled for, as the spec has them now - refused when one is not in
|
|
254
|
+
* the spec, or the module has no test reqs to write tests from: no code job would build it, or no test be written.
|
|
255
|
+
*/
|
|
256
|
+
const scheduledSpecs = (state, frids) => {
|
|
257
|
+
const tip = tipEntry(state);
|
|
258
|
+
const spec = chainSpec(state.chain);
|
|
259
|
+
const items = spec.tree[FUNCTIONAL_REQUIREMENTS] ?? [];
|
|
260
|
+
const unknown = frids.filter((frid) => !/^[1-9]\d*$/.test(frid) || Number(frid) > items.length);
|
|
261
|
+
if (unknown.length > 0) {
|
|
262
|
+
throw new Error(`${tip.moduleName} has ${items.length} functional spec${items.length === 1 ? "" : "s"}, and ${unknown.map((frid) => `fs ${frid}`).join(", ")} ` +
|
|
263
|
+
`${unknown.length === 1 ? "is not one of them" : "are not among them"}: no code pulse builds ${unknown.length === 1 ? "it" : "them"}, so no conformance ` +
|
|
264
|
+
"test could ever be written for it. Nothing was scheduled.");
|
|
265
|
+
}
|
|
266
|
+
if ((spec.tree[TEST_REQUIREMENTS] ?? []).length === 0) {
|
|
267
|
+
throw new Error(`${tip.moduleName} has no ***test reqs***: conformance tests are written from them, so none can be. Nothing was scheduled.`);
|
|
268
|
+
}
|
|
269
|
+
return frids.map((frid) => ({ frid, text: items[Number(frid) - 1].markdown.trim().split("\n")[0].replace(/^-\s*/, "").trim() }));
|
|
270
|
+
};
|
|
271
|
+
/** Functional specs of a module scheduled for conformance tests, beside those chosen before - as conform does for the module's own - in the chooser's pulses, when it planned them. */
|
|
272
|
+
const schedule = (state, frids, groups) => {
|
|
273
|
+
const before = state.conformance;
|
|
274
|
+
// Every one is tested already: nothing to add.
|
|
275
|
+
if (before !== undefined && before.chosen === undefined)
|
|
276
|
+
return;
|
|
277
|
+
let specs;
|
|
278
|
+
try {
|
|
279
|
+
specs = scheduledSpecs(state, frids);
|
|
280
|
+
}
|
|
281
|
+
catch (error) {
|
|
282
|
+
reportActivity(` ${tipEntry(state).moduleName}: the conformance tests chosen were not scheduled - ${error instanceof Error ? error.message : String(error)}`);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
state.conformance = { chosen: before?.chosen ?? new Set() };
|
|
286
|
+
state.scheduled = [...(state.scheduled ?? []), { specs, unbuilt: specs.map((touched) => touched.frid) }];
|
|
287
|
+
if (groups !== undefined && groups.length > 0) {
|
|
288
|
+
state.conformanceGroups = [...(state.conformanceGroups ?? []), ...groups];
|
|
289
|
+
state.conformancePlan = undefined;
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
/** The work of a track, from where it is built from to what it is to be. */
|
|
293
|
+
const workOf = (track, from, state, spec) => track === "code" ? codeWork(from, spec.tree) : changesBetween(serializeTree(from), serializeTree(conformanceOf(state, spec, from)));
|
|
294
|
+
/** The codebases brought up in this look: one left behind is never waited for. */
|
|
295
|
+
let looking = new Set();
|
|
296
|
+
/**
|
|
297
|
+
* The module below this one that it waits for, and why: one with a job running, code left to build, or conformance
|
|
298
|
+
* tests wanted and not yet written and passing - a codebase starts from the code and the conformance tests below it,
|
|
299
|
+
* so they pass before anything is built on them. Conformance tests below that did not pass hold it until they do.
|
|
300
|
+
*/
|
|
301
|
+
const waitingOn = (state) => {
|
|
302
|
+
for (const entry of state.chain.slice(0, -1)) {
|
|
303
|
+
const below = modules.get(entry.buildDir);
|
|
304
|
+
if (below === undefined || (!looking.has(below) && below.running === undefined))
|
|
305
|
+
continue;
|
|
306
|
+
const waiting = (why) => ({ module: entry.moduleName, why: `waiting for ${entry.moduleName}: ${why}` });
|
|
307
|
+
if (below.running !== undefined)
|
|
308
|
+
return waiting(below.running.planned.track === "conformance" ? "its conformance tests are being written" : "the code it is built on is not built yet");
|
|
309
|
+
const spec = chainSpec(below.chain);
|
|
310
|
+
if (codeWork(builtOf(below), spec.tree).length > 0)
|
|
311
|
+
return waiting("the code it is built on is not built yet");
|
|
312
|
+
if (below.conformanceHalted !== undefined)
|
|
313
|
+
return waiting(`its conformance tests did not pass - ${below.conformanceHalted.why}`);
|
|
314
|
+
if ((below.scheduled ?? []).length > 0)
|
|
315
|
+
return waiting("the conformance tests chosen for it are not written yet");
|
|
316
|
+
if (conformanceWanted(below, spec) && workOf("conformance", testedOf(below), below, spec).length > 0)
|
|
317
|
+
return waiting("its conformance tests are not written yet");
|
|
318
|
+
}
|
|
319
|
+
return undefined;
|
|
320
|
+
};
|
|
321
|
+
/**
|
|
322
|
+
* Steer the running code job when the spec moved on inside what it regenerates: its target moved to the spec as it
|
|
323
|
+
* now is there, its agent told - now, in its turn, or when the step it is at ends - its view in the panel moved too.
|
|
324
|
+
* Whatever else changed is the next job's. True when it was steered: not a third time, unless into a steer still
|
|
325
|
+
* waiting.
|
|
326
|
+
*/
|
|
327
|
+
const trySteer = (state, spec, own, work) => {
|
|
328
|
+
const running = state.running;
|
|
329
|
+
const steering = running?.steering;
|
|
330
|
+
if (running === undefined || steering === undefined || running.planned.track !== "code")
|
|
331
|
+
return false;
|
|
332
|
+
const waiting = waitingSteer(steering) !== undefined;
|
|
333
|
+
if (steering.steers >= MAX_STEERS && !waiting)
|
|
334
|
+
return false;
|
|
335
|
+
const moved = steer(steering, running.from, own, (tree) => workOf("code", tree, state, spec));
|
|
336
|
+
if (moved === undefined)
|
|
337
|
+
return false;
|
|
338
|
+
// Steered, everything it regenerates is wanted - else something it regenerates was taken out, and that is no steer.
|
|
339
|
+
if (!stillWanted(changesBetween(serializeTree(running.from), serializeTree(moved.target)), work))
|
|
340
|
+
return false;
|
|
341
|
+
const why = whyNotWanted(own, work);
|
|
342
|
+
steering.target = moved.target;
|
|
343
|
+
steering.agent = moved.agent;
|
|
344
|
+
if (!waiting)
|
|
345
|
+
steering.steers += 1;
|
|
346
|
+
steering.notes.push(why);
|
|
347
|
+
running.after = moved.target;
|
|
348
|
+
const text = waitingSteer(steering);
|
|
349
|
+
if (text !== undefined && steering.deliver?.(text) === true)
|
|
350
|
+
markTold(steering);
|
|
351
|
+
const tip = tipEntry(state);
|
|
352
|
+
const job = options.registry.get(running.jobId);
|
|
353
|
+
if (job !== undefined) {
|
|
354
|
+
const shown = changeDiffInFiles(agentChanges(changesBetween(serializeTree(running.from), serializeTree(steering.agent)), "code"), relative(projectDirOf(tip.plainFile), tip.plainFile) || basename(tip.plainFile), (diff) => whereInFiles(diff, state.chain));
|
|
355
|
+
job.diff = shown.diff;
|
|
356
|
+
job.diffWhere = shown.where;
|
|
357
|
+
}
|
|
358
|
+
reportActivity(` ⇢ steered pulse ${running.jobId} (${steering.steers} of ${MAX_STEERS}): ${why}`);
|
|
359
|
+
return true;
|
|
360
|
+
};
|
|
361
|
+
/** How the person wants a codebase's work planned: the conversation's and AGENTS.md's steering, then the requests'. */
|
|
362
|
+
const steeringOf = (state) => [
|
|
363
|
+
...(options.steering?.(state.askedFile) ?? []),
|
|
364
|
+
...state.guidance.map((text) => ({ source: "the request that asked for the work", text })),
|
|
365
|
+
];
|
|
366
|
+
const steeringKeyOf = (state) => JSON.stringify(steeringOf(state));
|
|
367
|
+
const plan = async (state, spec, from, track = "code") => {
|
|
368
|
+
const tip = tipEntry(state);
|
|
369
|
+
const current = track === "code" ? codeTree(spec.tree) : conformanceOf(state, spec, from);
|
|
370
|
+
const specFile = relative(options.cwd, tip.plainFile);
|
|
371
|
+
let specText = "";
|
|
372
|
+
try {
|
|
373
|
+
specText = readFileSync(tip.plainFile, "utf8");
|
|
374
|
+
}
|
|
375
|
+
catch {
|
|
376
|
+
/* the layout is only for how the diffs read */
|
|
377
|
+
}
|
|
378
|
+
const steering = steeringOf(state);
|
|
379
|
+
const separately = track === "conformance" && state.conformanceSeparately === true;
|
|
380
|
+
// How big a pulse may be: the module's config, else the defaults.
|
|
381
|
+
const config = configForSpec(tip.plainFile);
|
|
382
|
+
const limits = { specs: config.maxSpecsPerPulse ?? MAX_SPECS_PER_JOB, lines: config.maxLinesPerPulse ?? MAX_LINES_PER_JOB };
|
|
383
|
+
// The conformance tests the person asked for in groups of their own - 1-3, then 4-6: a group never shares a job with another.
|
|
384
|
+
const groups = track === "conformance" && state.conformanceGroups !== undefined && state.conformanceGroups.length > 0 ? state.conformanceGroups : undefined;
|
|
385
|
+
const input = { base: from, current, specFile, specText, steering, track, moduleName: tip.moduleName, limits, ...(separately ? { separately } : {}), ...(groups !== undefined ? { groups } : {}) };
|
|
386
|
+
const model = typeof options.plannerModel === "function" ? options.plannerModel() : options.plannerModel;
|
|
387
|
+
const agents = options.planner === false ? undefined : (options.planner ?? llmChunkAgents({ cwd: options.cwd, model: model ?? state.model, label: `${basename(tip.plainFile)} planner` }));
|
|
388
|
+
reportStage(`${tip.moduleName}: planning the ${track === "code" ? "work" : "conformance tests"} into pulses`);
|
|
389
|
+
const notes = [];
|
|
390
|
+
const draft = await planChunks(input, agents, notes);
|
|
391
|
+
const fresh = tip.requires === null && (track === "code" ? !started(tip) : readRecord(testsDirOf(state)) === null);
|
|
392
|
+
const jobs = stepsOf(input, draft).map((job, index) => {
|
|
393
|
+
// Its cases from its own change: the spec before it to the spec after it - the plan's changes are numbered from where the plan starts.
|
|
394
|
+
const cases = casesOf(parseTree(job.step.before), changesBetween(job.step.before, job.step.after), { fresh: fresh && index === 0, module: tip.moduleName });
|
|
395
|
+
return { title: job.title, reason: job.reason, cases, step: job.step, track };
|
|
396
|
+
});
|
|
397
|
+
return { jobs, notes };
|
|
398
|
+
};
|
|
399
|
+
/** Plan a track: while the planner works, the codebase says so rather than showing nothing - or, a job running, the old plan. */
|
|
400
|
+
const planIdle = async (state, spec, from, track) => {
|
|
401
|
+
state.planning = track;
|
|
402
|
+
state.planningSince = Date.now();
|
|
403
|
+
// Planning takes a while - the planner is an agent: what shows the work says so at once, not when the first pulse starts.
|
|
404
|
+
options.registry.notify?.();
|
|
405
|
+
try {
|
|
406
|
+
return await plan(state, spec, from, track);
|
|
407
|
+
}
|
|
408
|
+
finally {
|
|
409
|
+
state.planning = undefined;
|
|
410
|
+
state.planningSince = undefined;
|
|
411
|
+
options.registry.notify?.();
|
|
412
|
+
}
|
|
413
|
+
};
|
|
414
|
+
/** A dry run's first jobs, by codebase: what each would start. */
|
|
415
|
+
const firsts = new Map();
|
|
416
|
+
const startJob = (state, spec, from, planned) => {
|
|
417
|
+
const tip = tipEntry(state);
|
|
418
|
+
if (options.dryRun === true) {
|
|
419
|
+
firsts.set(tip.buildDir, { spec: planned.track === "conformance" ? spec : { ...spec, tree: planned.step.tree }, planned });
|
|
420
|
+
state.why = `would start: ${planned.title}`;
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
const conformance = planned.track === "conformance";
|
|
424
|
+
// A code job can be steered while it regenerates: its target moves with the spec, inside what it regenerates.
|
|
425
|
+
const steering = conformance ? undefined : createSteering(planned.step.tree, tip.moduleName);
|
|
426
|
+
// Shown as its agent is given it: the job's own items, from whichever file they are written in - its imports too.
|
|
427
|
+
const shown = changeDiffInFiles(agentChanges(planned.step.changes, planned.track), relative(projectDirOf(tip.plainFile), tip.plainFile) || basename(tip.plainFile), (diff) => whereInFiles(diff, state.chain));
|
|
428
|
+
const job = options.registry.start({
|
|
429
|
+
kind: conformance ? "conformance" : "regenerate",
|
|
430
|
+
summary: `${basename(tip.plainFile)} ${conformance ? "conformance: " : ""}${planned.title}`,
|
|
431
|
+
module: tip.moduleName,
|
|
432
|
+
// Numbered as regen numbers it: the module's pulses this session, from 1.
|
|
433
|
+
pulse: (state.pulses ?? 0) + 1,
|
|
434
|
+
title: planned.title,
|
|
435
|
+
diff: shown.diff,
|
|
436
|
+
...(state.model !== undefined ? { model: state.model } : {}),
|
|
437
|
+
lanes: [...moduleLanes(tip.plainFile, options.buildDir)],
|
|
438
|
+
rerun: `turri regenerate ${relative(options.cwd, tip.plainFile) || basename(tip.plainFile)}`,
|
|
439
|
+
run: async (onDelta) => {
|
|
440
|
+
// The guardrail: a conformance job tests only code that is built - the code record, read as the job starts, has every functional spec it tests as it tests it.
|
|
441
|
+
if (conformance) {
|
|
442
|
+
const testing = [...planned.cases.add, ...planned.cases.patch].map((touched) => touched.frid);
|
|
443
|
+
const missing = notBuilt(readRecord(tip.buildDir), planned.step.tree, testing);
|
|
444
|
+
if (missing.length > 0) {
|
|
445
|
+
const record = relative(options.cwd, join(tip.buildDir, RECORD_FILE));
|
|
446
|
+
const notBuiltText = `${missing.map((frid) => `fs ${frid}`).join(", ")} ${missing.length === 1 ? "is" : "are"} not built`;
|
|
447
|
+
return {
|
|
448
|
+
report: `Not started: this conformance pulse is to test ${testing.map((frid) => `fs ${frid}`).join(", ")}, and ${notBuiltText} - ` +
|
|
449
|
+
`${record} does not have ${missing.length === 1 ? "it" : "them"} as the spec has ${missing.length === 1 ? "it" : "them"}. ` +
|
|
450
|
+
"No conformance test is written for code that is not built. Tell the user, and see with drift which code pulse regenerates it.",
|
|
451
|
+
details: { passed: false, landed: false, notBuilt: notBuiltText },
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
// Nothing here yet: the codebase starts as a copy of the one it is built on, recorded as what it inherits.
|
|
456
|
+
if (tip.requires !== null && !started(tip)) {
|
|
457
|
+
const below = state.chain.find((entry) => entry.moduleName === tip.requires);
|
|
458
|
+
const source = below === undefined ? null : readRecord(below.buildDir);
|
|
459
|
+
if (below !== undefined && source !== null) {
|
|
460
|
+
copyRequiredCode(below.buildDir, tip.buildDir, tip.moduleName, below.moduleName);
|
|
461
|
+
// Its conformance tests come with the code, the ones below's: none of its own is written yet.
|
|
462
|
+
if (existsSync(join(testsDirOf(state), RECORD_FILE)))
|
|
463
|
+
writeTestsRecord(testsDirOf(state), {});
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
// A conformance job is shown the spec whole - its test reqs and acceptance tests are its to write from.
|
|
467
|
+
const result = await (conformance ? runConformance : run)({
|
|
468
|
+
plainFile: tip.plainFile,
|
|
469
|
+
moduleName: tip.moduleName,
|
|
470
|
+
buildDir: tip.buildDir,
|
|
471
|
+
spec: conformance ? spec : { ...spec, tree: planned.step.tree },
|
|
472
|
+
planned,
|
|
473
|
+
model: state.model,
|
|
474
|
+
cwd: options.cwd,
|
|
475
|
+
...(steering !== undefined ? { steering } : {}),
|
|
476
|
+
}, onDelta);
|
|
477
|
+
// Under conformance-tests: chosen or module, the chooser decides as the pulse's last stage - before it ends, so the
|
|
478
|
+
// choice is made, and its conformance pulse scheduled, before anything can be built on the module. Under chosen, of
|
|
479
|
+
// what this pulse regenerated; under module, once the module is regenerated, of all of it - and in which pulses.
|
|
480
|
+
let chose;
|
|
481
|
+
const when = chooseWhen(tip.plainFile);
|
|
482
|
+
if (result.landed && planned.track === "code" && when !== undefined && options.onChoose !== undefined) {
|
|
483
|
+
const now = chainSpec(state.chain);
|
|
484
|
+
const candidates = when === "pulse" ? toChoose(state, planned, now) : codeWork(builtOf(state), now.tree).length === 0 ? moduleToChoose(state, now) : [];
|
|
485
|
+
const left = budgetLeft(state, now);
|
|
486
|
+
if (candidates.length > 0 && left === 0)
|
|
487
|
+
chose = `conformance: the module's conformance budget is spent - none of fs ${candidates.map((touched) => touched.frid).join(", ")} is chosen`;
|
|
488
|
+
else if (candidates.length > 0) {
|
|
489
|
+
reportStage(`module ${tip.moduleName}: choosing its conformance tests`);
|
|
490
|
+
const picked = await options.onChoose(tip.plainFile, candidates, left, when);
|
|
491
|
+
// Within the budget, whatever was answered: the first that fit.
|
|
492
|
+
const frids = picked === undefined ? [] : left !== undefined ? picked.frids.slice(0, left) : picked.frids;
|
|
493
|
+
const groups = picked?.groups?.map((group) => group.filter((frid) => frids.includes(frid))).filter((group) => group.length > 0);
|
|
494
|
+
if (frids.length > 0)
|
|
495
|
+
schedule(state, frids, groups);
|
|
496
|
+
const why = picked?.why !== undefined ? ` - ${picked.why.replace(/\s+/g, " ").trim()}` : "";
|
|
497
|
+
chose =
|
|
498
|
+
frids.length > 0
|
|
499
|
+
? `conformance: fs ${frids.join(", ")} chosen${groups !== undefined && groups.length > 1 ? `, in ${groups.length} pulses (${groups.map((group) => group.join(", ")).join(" / ")})` : ""}${why}`
|
|
500
|
+
: `conformance: none of fs ${candidates.map((touched) => touched.frid).join(", ")} chosen${why}`;
|
|
501
|
+
}
|
|
502
|
+
if (chose !== undefined)
|
|
503
|
+
reportActivity(` ${tip.moduleName}: ${chose}`);
|
|
504
|
+
}
|
|
505
|
+
const report = chose !== undefined ? `${result.report}\n\n${chose}` : result.report;
|
|
506
|
+
// Released as its last stage, when the config says so: the module's next pulse starts on what is released.
|
|
507
|
+
let released;
|
|
508
|
+
if (result.landed && options.release !== undefined && configForSpec(tip.plainFile).releaseAfterPulse === true) {
|
|
509
|
+
reportStage(`module ${tip.moduleName}: releasing what this pulse landed (release-after-pulse)`);
|
|
510
|
+
try {
|
|
511
|
+
released = `Released after the pulse: ${(await options.release({ plainFile: tip.plainFile, moduleName: tip.moduleName, title: planned.title, onDelta })).trim()}`;
|
|
512
|
+
}
|
|
513
|
+
catch (error) {
|
|
514
|
+
// What landed stays landed: a release that fails is said, and the next pulse goes on.
|
|
515
|
+
released = `The release after the pulse failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
516
|
+
}
|
|
517
|
+
reportActivity(` ${released.split("\n")[0]}`);
|
|
518
|
+
}
|
|
519
|
+
return {
|
|
520
|
+
report: released !== undefined ? `${report}\n\n${released}` : report,
|
|
521
|
+
details: { passed: result.landed, landed: result.landed, ...(released !== undefined ? { released: true } : {}), ...(result.story !== undefined ? { story: result.story } : {}) },
|
|
522
|
+
};
|
|
523
|
+
},
|
|
524
|
+
});
|
|
525
|
+
// Its lines where they are written: the module's .plain file, those it imports, and those of the modules below it.
|
|
526
|
+
job.diffWhere = shown.where;
|
|
527
|
+
state.pulses = (state.pulses ?? 0) + 1;
|
|
528
|
+
state.running = { jobId: job.id, number: state.pulses, planned, from, after: planned.step.tree, ...(steering !== undefined ? { steering } : {}) };
|
|
529
|
+
void options.registry.wait(job.id).then((settled) => settle(state, settled), () => settle(state, undefined));
|
|
530
|
+
};
|
|
531
|
+
/** A pulse as a halt keeps it: its number, and the trees it went between. */
|
|
532
|
+
// A codebase taken back to nothing, and what the loop held of it - plans, halts - with it: built again from the start.
|
|
533
|
+
const startAgain = (entry, why) => {
|
|
534
|
+
startFromScratch(entry, why);
|
|
535
|
+
const state = modules.get(entry.buildDir);
|
|
536
|
+
if (state === undefined)
|
|
537
|
+
return;
|
|
538
|
+
state.plan = undefined;
|
|
539
|
+
state.conformancePlan = undefined;
|
|
540
|
+
state.halted = undefined;
|
|
541
|
+
state.conformanceHalted = undefined;
|
|
542
|
+
state.startOver = undefined;
|
|
543
|
+
};
|
|
544
|
+
// A codebase built on a module whose conformance tests the person asked for: disposable - deleted whole, its history with
|
|
545
|
+
// it - and what the loop held of it, so it is regenerated from the start, on the module once its tests pass.
|
|
546
|
+
const discard = (entry, why) => {
|
|
547
|
+
rmSync(dirname(entry.buildDir), { recursive: true, force: true });
|
|
548
|
+
reportActivity(` ${entry.moduleName}: its codebase is deleted - ${why}`);
|
|
549
|
+
const state = modules.get(entry.buildDir);
|
|
550
|
+
if (state === undefined)
|
|
551
|
+
return;
|
|
552
|
+
state.plan = undefined;
|
|
553
|
+
state.conformancePlan = undefined;
|
|
554
|
+
// What the person asked of it - conformance tests chosen or scheduled - stays: it is asked of the code regenerated.
|
|
555
|
+
state.halted = undefined;
|
|
556
|
+
state.conformanceHalted = undefined;
|
|
557
|
+
state.startOver = undefined;
|
|
558
|
+
};
|
|
559
|
+
const pulseOf = (running) => ({ number: running.number, before: serializeTree(running.from), after: serializeTree(running.after) });
|
|
560
|
+
// What went wrong in a pulse that did not land, in a few words: the tests it stopped at, else its error's first line.
|
|
561
|
+
const causeOf = (job) => {
|
|
562
|
+
const stoppedAt = job?.details?.stoppedAt;
|
|
563
|
+
if (stoppedAt === "unit tests" || stoppedAt === "conformance" || stoppedAt === "regression") {
|
|
564
|
+
return { cause: stoppedAt === "unit tests" ? "its unit tests never passed" : stoppedAt === "conformance" ? "its conformance tests never passed" : "the whole conformance suite never passed" };
|
|
565
|
+
}
|
|
566
|
+
const error = job?.error?.split("\n").find((line) => line.trim() !== "")?.trim();
|
|
567
|
+
return error !== undefined ? { cause: error } : {};
|
|
568
|
+
};
|
|
569
|
+
const settle = (state, settled) => {
|
|
570
|
+
const running = state.running;
|
|
571
|
+
state.running = undefined;
|
|
572
|
+
const landed = settled?.status === "succeeded" && settled.details?.landed === true;
|
|
573
|
+
if (running !== undefined && !landed && running.planned.track === "conformance") {
|
|
574
|
+
// Conformance tests that did not pass - nor the code fixes they made - are gone, and they halt: the code goes on.
|
|
575
|
+
resetToLastCommit(tipEntry(state).buildDir);
|
|
576
|
+
state.conformancePlan = undefined;
|
|
577
|
+
if (running.stopping !== true) {
|
|
578
|
+
const unbuilt = settled?.details?.notBuilt;
|
|
579
|
+
const why = settled?.status === "stopped"
|
|
580
|
+
? `pulse ${running.jobId} was stopped`
|
|
581
|
+
: unbuilt !== undefined
|
|
582
|
+
? `conformance pulse ${running.jobId} ("${running.planned.title}") did not start: ${unbuilt}`
|
|
583
|
+
: `conformance pulse ${running.jobId} ("${running.planned.title}") did not pass`;
|
|
584
|
+
state.conformanceHalted = { why: `${why} - its conformance tests are written again once the spec or the code changes, or they are asked for again`, key: conformanceKey(state) };
|
|
585
|
+
// Nothing more is built on code whose conformance tests did not pass: its code halts too, and what is above waits.
|
|
586
|
+
let key = "";
|
|
587
|
+
try {
|
|
588
|
+
key = keyOf(chainSpec(state.chain));
|
|
589
|
+
}
|
|
590
|
+
catch {
|
|
591
|
+
/* a spec that does not parse halts it all the same */
|
|
592
|
+
}
|
|
593
|
+
state.halted = { why: `${why} - nothing more is built on it until its conformance tests pass`, specKey: key, pulse: pulseOf(running), ...(settled?.status === "stopped" ? {} : causeOf(settled)) };
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
else if (running !== undefined && !landed) {
|
|
597
|
+
// What it wrote and never committed is gone: code, tests and record agree again.
|
|
598
|
+
resetToLastCommit(tipEntry(state).buildDir);
|
|
599
|
+
state.plan = undefined;
|
|
600
|
+
// Stopped for the conformance tests of a module below: the codebase is deleted, and regenerated from it after.
|
|
601
|
+
if (running.discard !== undefined)
|
|
602
|
+
discard(tipEntry(state), running.discard);
|
|
603
|
+
if (running.stopping !== true) {
|
|
604
|
+
let key = "";
|
|
605
|
+
try {
|
|
606
|
+
key = keyOf(chainSpec(state.chain));
|
|
607
|
+
}
|
|
608
|
+
catch {
|
|
609
|
+
/* a spec that does not parse halts it all the same */
|
|
610
|
+
}
|
|
611
|
+
const why = settled?.status === "stopped" ? `pulse ${running.jobId} was stopped` : `pulse ${running.jobId} ("${running.planned.title}") did not land`;
|
|
612
|
+
state.halted = { why: `${why} - it runs again once the spec changes, or the module is asked for again`, specKey: key, pulse: pulseOf(running), ...(settled?.status === "stopped" ? {} : causeOf(settled)) };
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
else {
|
|
616
|
+
const own = running?.planned.track === "conformance" ? state.conformancePlan : state.plan;
|
|
617
|
+
if (own !== undefined) {
|
|
618
|
+
own.jobs.shift();
|
|
619
|
+
if (running !== undefined)
|
|
620
|
+
own.from = serializeTree(running.after);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
// What it did, in the notes every agent after reads - once anything it did not land is rolled back, so the line stays.
|
|
624
|
+
if (running !== undefined)
|
|
625
|
+
notePulse(state, running, settled, landed);
|
|
626
|
+
void tick();
|
|
627
|
+
};
|
|
628
|
+
/**
|
|
629
|
+
* A pulse's line in the notes, written as it ends: what it regenerated, whether it landed, how it went - the rounds,
|
|
630
|
+
* the test runs, what the debugger found - and where its log is, for whoever wants the whole of it. Committed
|
|
631
|
+
* with it.
|
|
632
|
+
*/
|
|
633
|
+
const notePulse = (state, running, settled, landed) => {
|
|
634
|
+
const tip = tipEntry(state);
|
|
635
|
+
const details = settled?.details;
|
|
636
|
+
const outcome = landed
|
|
637
|
+
? "landed"
|
|
638
|
+
: settled?.status === "stopped" || running.stopping === true
|
|
639
|
+
? "stopped - rolled back"
|
|
640
|
+
: details?.notBuilt !== undefined
|
|
641
|
+
? `did not start: ${details.notBuilt}`
|
|
642
|
+
: "did not pass - rolled back";
|
|
643
|
+
const log = settled?.logFile !== undefined ? ` · log ${relative(projectDirOf(tip.plainFile), settled.logFile)}` : "";
|
|
644
|
+
const took = settled !== undefined ? ` · ${formatDuration(settled)}${settled.usage.cost > 0 ? ` · $${settled.usage.cost.toFixed(2)}` : ""}` : "";
|
|
645
|
+
const what = `${running.planned.track === "conformance" ? "conformance tests: " : ""}${running.planned.title}`;
|
|
646
|
+
// A conformance pulse's line is the tests' notes', beside them: the coding agents never read of the black-box tests.
|
|
647
|
+
const notesDir = running.planned.track === "conformance" ? testsDirOf(state) : tip.buildDir;
|
|
648
|
+
recordPulse(notesDir, `${what} - ${outcome}${details?.story !== undefined ? `; ${details.story}` : ""}${took}${log}`, `${tip.moduleName} pulse ${running.number}`);
|
|
649
|
+
commitNotes(notesDir, NOTES_FILE, `pulse ${running.number} ${landed ? "landed" : "rolled back"}: ${what}`, landed ? (settled?.startedAt ?? Date.now()) : undefined);
|
|
650
|
+
};
|
|
651
|
+
/**
|
|
652
|
+
* A record written before linked resources were hashed has the hashes of the files as they are now - committed, with
|
|
653
|
+
* nothing running there - so the code is taken to be built from them, rather than everything linking one built again.
|
|
654
|
+
*/
|
|
655
|
+
const hashResources = (state, spec) => {
|
|
656
|
+
// A dry run only reads.
|
|
657
|
+
if (options.dryRun === true)
|
|
658
|
+
return;
|
|
659
|
+
const code = tipEntry(state).buildDir;
|
|
660
|
+
for (const dir of [code, testsDirOf(state)]) {
|
|
661
|
+
const record = readRecord(dir);
|
|
662
|
+
const hashed = adoptDigests(record, spec.tree);
|
|
663
|
+
if (record === null || hashed === null || serializeTree(hashed) === serializeTree(record))
|
|
664
|
+
continue;
|
|
665
|
+
(dir === code ? writeCodeRecord : writeTestsRecord)(dir, hashed);
|
|
666
|
+
recordHistory(dir, "the record's linked resources hashed, as their files are now");
|
|
667
|
+
}
|
|
668
|
+
};
|
|
669
|
+
/** What a conformance halt holds for: the spec and what the code is built from, as they are. */
|
|
670
|
+
const conformanceKey = (state) => {
|
|
671
|
+
try {
|
|
672
|
+
return `${serializeTree(chainSpec(state.chain).tree)}\n${serializeTree(readRecord(tipEntry(state).buildDir) ?? {})}`;
|
|
673
|
+
}
|
|
674
|
+
catch {
|
|
675
|
+
return "";
|
|
676
|
+
}
|
|
677
|
+
};
|
|
678
|
+
/** Look at every codebase once, bottom of each chain first, and start what can start. */
|
|
679
|
+
const tick = () => {
|
|
680
|
+
ticking = ticking
|
|
681
|
+
.then(() => withParseCache(() => lookAll()))
|
|
682
|
+
.catch((error) => reportActivity(` the work loop: ${error instanceof Error ? error.message : String(error)}`));
|
|
683
|
+
return ticking;
|
|
684
|
+
};
|
|
685
|
+
/** The files asked for, each with how: what the loop brings up, worked out again each look. */
|
|
686
|
+
const asked = new Map();
|
|
687
|
+
/** The codebases the requests bring up now - where each module's code lives, and what is to be built on it. */
|
|
688
|
+
const active = () => {
|
|
689
|
+
const states = new Set();
|
|
690
|
+
for (const [file, request] of asked) {
|
|
691
|
+
let chains;
|
|
692
|
+
try {
|
|
693
|
+
chains = chainsFor(file);
|
|
694
|
+
}
|
|
695
|
+
catch {
|
|
696
|
+
continue;
|
|
697
|
+
}
|
|
698
|
+
for (const chain of chains)
|
|
699
|
+
for (const entry of toBuild(chain))
|
|
700
|
+
states.add(track(chain.slice(0, chain.indexOf(entry) + 1), file, request));
|
|
701
|
+
}
|
|
702
|
+
return states;
|
|
703
|
+
};
|
|
704
|
+
const lookAll = async () => {
|
|
705
|
+
const now = active();
|
|
706
|
+
looking = now;
|
|
707
|
+
for (const key of order) {
|
|
708
|
+
const state = modules.get(key);
|
|
709
|
+
// A codebase left behind - built on since - is not looked at, unless a job of it is still running.
|
|
710
|
+
if (now.has(state) || state.running !== undefined)
|
|
711
|
+
await look(state);
|
|
712
|
+
else
|
|
713
|
+
state.why = "left behind: the code is built on further up the chain, where changes to it are made";
|
|
714
|
+
}
|
|
715
|
+
};
|
|
716
|
+
const look = async (state) => {
|
|
717
|
+
const tip = tipEntry(state);
|
|
718
|
+
let spec;
|
|
719
|
+
try {
|
|
720
|
+
spec = chainSpec(state.chain);
|
|
721
|
+
}
|
|
722
|
+
catch (error) {
|
|
723
|
+
state.why = `its spec does not parse: ${error instanceof Error ? error.message : String(error)}`;
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
const specKey = keyOf(spec);
|
|
727
|
+
state.seen = spec;
|
|
728
|
+
promote(state, spec);
|
|
729
|
+
if (state.running !== undefined) {
|
|
730
|
+
if (state.running.stopping === true)
|
|
731
|
+
return;
|
|
732
|
+
// The stop rule: every change the running job makes is still part of the work, measured from the same record.
|
|
733
|
+
const track = state.running.planned.track;
|
|
734
|
+
const own = changesBetween(serializeTree(state.running.from), serializeTree(state.running.after));
|
|
735
|
+
let work = workOf(track, state.running.from, state, spec);
|
|
736
|
+
// A change inside what it regenerates steers it, rather than stopping it or leaving it to the next job.
|
|
737
|
+
if (!stillWanted(own, work) && trySteer(state, spec, own, work))
|
|
738
|
+
work = workOf(track, state.running.from, state, spec);
|
|
739
|
+
const ownNow = changesBetween(serializeTree(state.running.from), serializeTree(state.running.after));
|
|
740
|
+
if (!stillWanted(ownNow, work)) {
|
|
741
|
+
const why = whyNotWanted(ownNow, work);
|
|
742
|
+
// The project may have it run to its end instead: what it lands, the next job regenerates the change on top of.
|
|
743
|
+
if (configForSpec(tip.plainFile).allowToStopJobs === false) {
|
|
744
|
+
if (state.running.keptDespite !== why) {
|
|
745
|
+
state.running.keptDespite = why;
|
|
746
|
+
reportActivity(` ${tip.moduleName}: pulse ${state.running.jobId} runs on although ${why} - allow-to-stop-jobs is false: the change is regenerated on top of what it lands`);
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
else {
|
|
750
|
+
state.running.stopping = true;
|
|
751
|
+
state.why = `pulse ${state.running.jobId} was stopped: ${why}, which it was regenerating from`;
|
|
752
|
+
reportActivity(` ${tip.moduleName}: ${state.why}`);
|
|
753
|
+
options.registry.stop(state.running.jobId, `Stopped by turri, not by you: ${why}, which it was regenerating from. What it had done is rolled back, and the spec as it is now is regenerated instead. To have a running pulse finish first and the change regenerated on top of it, set allow-to-stop-jobs: false in config.turri.yaml.`);
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
// Still wanted: what comes after it is planned now, from what it will land - not once it has. The steering
|
|
758
|
+
// changed, the code's is planned again too: what is not running yet follows it.
|
|
759
|
+
const planOf = track === "code" ? state.plan : state.conformancePlan;
|
|
760
|
+
const steeringKey = steeringKeyOf(state);
|
|
761
|
+
if (planOf === undefined || planOf.specKey !== specKey || (track === "code" && state.plan?.steeringKey !== steeringKey)) {
|
|
762
|
+
const running = state.running;
|
|
763
|
+
// Shown as being planned on the regeneration; the status goes on showing the running job.
|
|
764
|
+
const rest = workOf(track, running.after, state, spec).length === 0 ? { jobs: [], notes: [] } : await planIdle(state, spec, running.after, track);
|
|
765
|
+
const next = { specKey, from: serializeTree(running.from), jobs: [running.planned, ...rest.jobs] };
|
|
766
|
+
if (state.running === running) {
|
|
767
|
+
if (track === "code")
|
|
768
|
+
state.plan = { ...next, steeringKey, notes: rest.notes };
|
|
769
|
+
else
|
|
770
|
+
state.conformancePlan = { ...next, codeKey: codeKeyOf(state) };
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
hashResources(state, spec);
|
|
776
|
+
if (state.halted !== undefined) {
|
|
777
|
+
if (state.halted.specKey === specKey) {
|
|
778
|
+
state.why = state.halted.why;
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
state.halted = undefined;
|
|
782
|
+
}
|
|
783
|
+
const fresh = newlyRequired(state.chain);
|
|
784
|
+
state.startOver = undefined;
|
|
785
|
+
if (fresh !== undefined && fresh.rebuild.some((entry) => entry.buildDir === tip.buildDir)) {
|
|
786
|
+
const codebases = fresh.rebuild.map((entry) => entry.moduleName);
|
|
787
|
+
state.startOver = { requires: fresh.modules, codebases };
|
|
788
|
+
state.why =
|
|
789
|
+
`it now requires ${fresh.modules.join(", ")}, which its code was not built on: it is built only once ` +
|
|
790
|
+
`${codebases.join(", ")} ${codebases.length === 1 ? "is" : "are"} started over - code and conformance tests taken out, kept in the history, and built again on ${fresh.modules.join(", ")}`;
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
const below = waitingOn(state);
|
|
794
|
+
state.waitsFor = below?.module;
|
|
795
|
+
if (below !== undefined) {
|
|
796
|
+
state.why = below.why;
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
// The module's code first, every job of it; then its conformance tests - once, for the module as built, with one
|
|
800
|
+
// explorer and one run of the whole suite - and only then is anything built on it (waitingOn).
|
|
801
|
+
const from = builtOf(state);
|
|
802
|
+
const codeLeft = codeWork(from, spec.tree).length > 0;
|
|
803
|
+
if (state.conformanceHalted !== undefined && state.conformanceHalted.key !== conformanceKey(state))
|
|
804
|
+
state.conformanceHalted = undefined;
|
|
805
|
+
if (!codeLeft && conformanceWanted(state, spec) && state.conformanceHalted === undefined) {
|
|
806
|
+
const tested = testedOf(state);
|
|
807
|
+
if (workOf("conformance", tested, state, spec).length > 0) {
|
|
808
|
+
const testedKey = serializeTree(tested);
|
|
809
|
+
const existing = state.conformancePlan;
|
|
810
|
+
// Made again when the spec, the tests or the code it was planned against moved: what the tests are written for
|
|
811
|
+
// follows the code, and a plan made before a code pulse landed would test it as it was.
|
|
812
|
+
if (existing === undefined || existing.specKey !== specKey || existing.from !== testedKey || existing.codeKey !== codeKeyOf(state) || existing.jobs.length === 0) {
|
|
813
|
+
state.conformancePlan = { specKey, from: testedKey, codeKey: codeKeyOf(state), jobs: (await planIdle(state, spec, tested, "conformance")).jobs };
|
|
814
|
+
}
|
|
815
|
+
const next = state.conformancePlan.jobs[0];
|
|
816
|
+
if (next !== undefined) {
|
|
817
|
+
state.why = undefined;
|
|
818
|
+
startJob(state, spec, tested, next);
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
else {
|
|
823
|
+
state.conformancePlan = undefined;
|
|
824
|
+
state.together = undefined;
|
|
825
|
+
state.conformanceGroups = undefined;
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
if (!codeLeft) {
|
|
829
|
+
state.plan = undefined;
|
|
830
|
+
state.why = state.conformanceHalted?.why ?? UP_TO_DATE;
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
const fromKey = serializeTree(from);
|
|
834
|
+
const steeringKey = steeringKeyOf(state);
|
|
835
|
+
if (state.plan === undefined || state.plan.specKey !== specKey || state.plan.from !== fromKey || state.plan.steeringKey !== steeringKey || state.plan.jobs.length === 0) {
|
|
836
|
+
const { jobs, notes } = await planIdle(state, spec, from, "code");
|
|
837
|
+
state.plan = { specKey, from: fromKey, steeringKey, jobs, notes };
|
|
838
|
+
if (jobs.length === 0) {
|
|
839
|
+
state.why = NO_PLAN;
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
state.why = undefined;
|
|
844
|
+
startJob(state, spec, from, state.plan.jobs[0]);
|
|
845
|
+
};
|
|
846
|
+
const statusOf = (state) => describe(state).status;
|
|
847
|
+
/** A codebase's status, and beside each job it lists as planned, the planned job it is - none for one being planned or scheduled. */
|
|
848
|
+
const describe = (state) => {
|
|
849
|
+
// What runs next: the code's jobs, then the conformance tests' - the module's, once its code is built - the running job first.
|
|
850
|
+
const running = state.running?.planned;
|
|
851
|
+
const own = (jobs) => (jobs ?? []).filter((job) => job !== running);
|
|
852
|
+
// A track being planned again shows as that, not as its plan before.
|
|
853
|
+
const replanning = state.running === undefined ? state.planning : undefined;
|
|
854
|
+
const jobs = [
|
|
855
|
+
...(running !== undefined ? [running] : []),
|
|
856
|
+
...(replanning === "code" ? [] : own(state.plan?.jobs)),
|
|
857
|
+
...(replanning === "conformance" ? [] : own(state.conformancePlan?.jobs)),
|
|
858
|
+
];
|
|
859
|
+
// A scheduled conformance job comes after the module's last code job - last, when none is planned.
|
|
860
|
+
const lastCode = jobs.findLastIndex((job) => job.track === "code");
|
|
861
|
+
const after = (state.scheduled ?? []).map(() => lastCode);
|
|
862
|
+
const scheduledAt = (index) => (state.scheduled ?? []).flatMap((group, at) => after[at] !== index
|
|
863
|
+
? []
|
|
864
|
+
: [{ title: group.specs.map((touched) => `fs ${touched.frid}`).join(", "), cases: { fresh: false, add: group.specs, patch: [], again: [], delete: [], mechanical: false }, track: "conformance", waitsFor: group.unbuilt }]);
|
|
865
|
+
// Being planned, nothing running: what is next - for the conformance tests, the groups chosen that it plans - not nothing.
|
|
866
|
+
const together = (state.together ?? []).flat().map((touched) => `fs ${touched.frid}`);
|
|
867
|
+
const planningNow = state.running === undefined && state.planning !== undefined
|
|
868
|
+
? [
|
|
869
|
+
{
|
|
870
|
+
title: state.planning === "conformance" ? (together.length > 0 ? together.join(", ") : "the conformance tests") : "the work",
|
|
871
|
+
cases: { fresh: false, add: [], patch: [], again: [], delete: [], mechanical: false },
|
|
872
|
+
track: state.planning,
|
|
873
|
+
planning: true,
|
|
874
|
+
},
|
|
875
|
+
]
|
|
876
|
+
: [];
|
|
877
|
+
// The module's conformance tests, wanted for all of it while code is still to build: planned once it is built, shown now.
|
|
878
|
+
const codeToBuild = lastCode !== -1 || (state.running === undefined && state.planning === "code");
|
|
879
|
+
const testReqs = (state.seen?.tree[TEST_REQUIREMENTS] ?? []).length > 0;
|
|
880
|
+
const plainFile = tipEntry(state).plainFile;
|
|
881
|
+
const wholeModule = (state.conformance !== undefined && state.conformance.chosen === undefined) || (configConformance(plainFile) && !configChosen(plainFile));
|
|
882
|
+
const afterCode = codeToBuild && testReqs && wholeModule && state.conformanceHalted === undefined && !jobs.some((job) => job.track === "conformance")
|
|
883
|
+
? [{ title: "conformance tests", cases: { fresh: false, add: [], patch: [], again: [], delete: [], mechanical: false }, track: "conformance", afterCode: true }]
|
|
884
|
+
: [];
|
|
885
|
+
const listed = [
|
|
886
|
+
...planningNow.map((entry) => ({ entry })),
|
|
887
|
+
...jobs.flatMap((job, index) => [{ entry: { title: job.title, cases: job.cases, track: job.track }, job }, ...scheduledAt(index).map((entry) => ({ entry }))]),
|
|
888
|
+
...scheduledAt(-1).map((entry) => ({ entry })),
|
|
889
|
+
...afterCode.map((entry) => ({ entry })),
|
|
890
|
+
];
|
|
891
|
+
const planned = listed.map((item) => item.entry);
|
|
892
|
+
const tip = tipEntry(state);
|
|
893
|
+
const status = {
|
|
894
|
+
module: tip.moduleName,
|
|
895
|
+
plainFile: tip.plainFile,
|
|
896
|
+
...(state.running !== undefined
|
|
897
|
+
? {
|
|
898
|
+
running: {
|
|
899
|
+
jobId: state.running.jobId,
|
|
900
|
+
title: state.running.planned.title,
|
|
901
|
+
...(state.running.steering !== undefined && state.running.steering.steers > 0 ? { steered: [...state.running.steering.notes] } : {}),
|
|
902
|
+
},
|
|
903
|
+
}
|
|
904
|
+
: {}),
|
|
905
|
+
planned: state.running !== undefined ? planned.slice(1) : planned,
|
|
906
|
+
...(replanning !== "code" && (state.plan?.notes.length ?? 0) > 0 ? { planNotes: [...state.plan.notes] } : {}),
|
|
907
|
+
...(state.running === undefined && state.why !== undefined ? { why: state.why } : {}),
|
|
908
|
+
...(state.running === undefined && state.startOver !== undefined ? { startOver: state.startOver } : {}),
|
|
909
|
+
};
|
|
910
|
+
return { status, jobs: (state.running !== undefined ? listed.slice(1) : listed).map((item) => item.job), scheduled: (state.running !== undefined ? listed.slice(1) : listed).map((item) => (item.entry.waitsFor !== undefined ? { title: item.entry.title, specs: item.entry.cases.add.map((touched) => touched.text) } : undefined)) };
|
|
911
|
+
};
|
|
912
|
+
/** Look after a codebase - its chain up to it - from now on; says its state. */
|
|
913
|
+
const track = (chain, askedFile, request) => {
|
|
914
|
+
const tip = tipOf(chain);
|
|
915
|
+
let state = modules.get(tip.buildDir);
|
|
916
|
+
if (state === undefined) {
|
|
917
|
+
state = { chain, askedFile, guidance: [] };
|
|
918
|
+
modules.set(tip.buildDir, state);
|
|
919
|
+
// Bottom of the chain first: a codebase is looked at after those it is built on.
|
|
920
|
+
const at = order.findIndex((key) => modules.get(key).chain.some((entry) => entry.buildDir === tip.buildDir));
|
|
921
|
+
if (at === -1)
|
|
922
|
+
order.push(tip.buildDir);
|
|
923
|
+
else
|
|
924
|
+
order.splice(at, 0, tip.buildDir);
|
|
925
|
+
}
|
|
926
|
+
state.chain = chain;
|
|
927
|
+
state.askedFile = askedFile;
|
|
928
|
+
const model = request.model ?? options.modelFor?.(tip.plainFile);
|
|
929
|
+
if (model !== undefined)
|
|
930
|
+
state.model = model;
|
|
931
|
+
if (request.guidance !== undefined && !state.guidance.includes(request.guidance))
|
|
932
|
+
state.guidance.push(request.guidance);
|
|
933
|
+
return state;
|
|
934
|
+
};
|
|
935
|
+
const loop = {
|
|
936
|
+
async bringUp(plainFile, request = {}) {
|
|
937
|
+
const file = resolve(options.cwd, plainFile);
|
|
938
|
+
asked.set(file, { ...asked.get(file), ...request });
|
|
939
|
+
const states = withParseCache(() => chainsFor(file).flatMap((chain) => toBuild(chain).map((entry) => track(chain.slice(0, chain.indexOf(entry) + 1), file, request))));
|
|
940
|
+
// Asked again, a halted codebase is tried again.
|
|
941
|
+
for (const state of states)
|
|
942
|
+
state.halted = undefined;
|
|
943
|
+
await tick();
|
|
944
|
+
return [...new Set(states)].map(statusOf);
|
|
945
|
+
},
|
|
946
|
+
async regenerateAgain(plainFile, given, request = {}) {
|
|
947
|
+
const frids = [...new Set(Array.isArray(given) ? given : [given])];
|
|
948
|
+
const asked = resolve(options.cwd, plainFile);
|
|
949
|
+
const own = resolveRequiresChain(asked, [], undefined, { allowNoFunctionalities: true });
|
|
950
|
+
const module = tipOf(own).moduleName;
|
|
951
|
+
const chains = chainsFor(asked);
|
|
952
|
+
for (const chain of chains) {
|
|
953
|
+
const tip = tipOf(chain);
|
|
954
|
+
if (modules.get(tip.buildDir)?.running !== undefined)
|
|
955
|
+
throw new Error(`${tip.moduleName} has a pulse running: ask again once it is done.`);
|
|
956
|
+
}
|
|
957
|
+
const found = new Set();
|
|
958
|
+
for (const chain of chains) {
|
|
959
|
+
const tip = tipOf(chain);
|
|
960
|
+
const record = readRecord(tip.buildDir);
|
|
961
|
+
if (record === null)
|
|
962
|
+
continue;
|
|
963
|
+
const section = tip.moduleName === module ? FUNCTIONAL_REQUIREMENTS : REQUIRED_FUNCTIONAL_SPECS;
|
|
964
|
+
const items = record[section] ?? [];
|
|
965
|
+
const named = (item, index) => section === FUNCTIONAL_REQUIREMENTS ? String(index + 1) : item.module === module ? (item.frid ?? "") : "";
|
|
966
|
+
const taken = items.flatMap((item, index) => (frids.includes(named(item, index)) ? [named(item, index)] : []));
|
|
967
|
+
if (taken.length === 0)
|
|
968
|
+
continue;
|
|
969
|
+
for (const frid of taken)
|
|
970
|
+
found.add(frid);
|
|
971
|
+
const marked = items.map((item, index) => (frids.includes(named(item, index)) ? { ...item, [REGENERATE_AGAIN]: true } : item));
|
|
972
|
+
writeCodeRecord(tip.buildDir, { ...record, [section]: marked });
|
|
973
|
+
recordHistory(tip.buildDir, `${module} functional spec${taken.length === 1 ? "" : "s"} ${taken.join(", ")} to regenerate again`);
|
|
974
|
+
}
|
|
975
|
+
const missing = frids.filter((frid) => !found.has(frid));
|
|
976
|
+
if (found.size === 0)
|
|
977
|
+
throw new Error(`${module} has no functional spec ${missing.join(", ")} built anywhere, so there is nothing to regenerate again: regenerate builds what is not built.`);
|
|
978
|
+
const statuses = await loop.bringUp(plainFile, request);
|
|
979
|
+
if (missing.length > 0)
|
|
980
|
+
reportActivity(` ${module}: functional spec ${missing.join(", ")} is not built anywhere - nothing to regenerate again there`);
|
|
981
|
+
return statuses;
|
|
982
|
+
},
|
|
983
|
+
drift(plainFile) {
|
|
984
|
+
const file = resolve(options.cwd, plainFile);
|
|
985
|
+
return withParseCache(() => chainsFor(file).flatMap((chain) => toBuild(chain).map((entry) => {
|
|
986
|
+
const codebase = chain.slice(0, chain.indexOf(entry) + 1);
|
|
987
|
+
const tip = tipOf(codebase);
|
|
988
|
+
// The codebase as the loop knows it, or - never asked for - as its records have it.
|
|
989
|
+
const state = modules.get(tip.buildDir) ?? { chain: codebase, askedFile: file, guidance: [] };
|
|
990
|
+
const report = { module: tip.moduleName, plainFile: tip.plainFile, code: undefined, conformance: undefined, scheduled: [] };
|
|
991
|
+
let spec;
|
|
992
|
+
try {
|
|
993
|
+
spec = chainSpec(codebase);
|
|
994
|
+
}
|
|
995
|
+
catch (error) {
|
|
996
|
+
return { ...report, why: `its spec does not parse: ${error instanceof Error ? error.message : String(error)}` };
|
|
997
|
+
}
|
|
998
|
+
const from = builtOf(state);
|
|
999
|
+
const fresh = tip.requires === null && !started(tip);
|
|
1000
|
+
const code = codeWork(from, spec.tree);
|
|
1001
|
+
report.code = code.length === 0 ? null : casesOf(from, code, { fresh, module: tip.moduleName });
|
|
1002
|
+
if (conformanceWanted(state, spec)) {
|
|
1003
|
+
const tested = testedOf(state);
|
|
1004
|
+
const tests = workOf("conformance", tested, state, spec);
|
|
1005
|
+
report.conformance = tests.length === 0 ? null : casesOf(tested, tests, { module: tip.moduleName });
|
|
1006
|
+
}
|
|
1007
|
+
report.scheduled = (state.scheduled ?? []).map((group) => ({ frids: group.specs.map((touched) => touched.frid), unbuilt: group.unbuilt }));
|
|
1008
|
+
if (state.running !== undefined)
|
|
1009
|
+
report.running = { jobId: state.running.jobId, title: state.running.planned.title };
|
|
1010
|
+
const halted = state.halted?.why ?? state.conformanceHalted?.why;
|
|
1011
|
+
if (halted !== undefined)
|
|
1012
|
+
report.why = halted;
|
|
1013
|
+
if (state.startOver !== undefined)
|
|
1014
|
+
report.startOver = state.startOver;
|
|
1015
|
+
return report;
|
|
1016
|
+
})));
|
|
1017
|
+
},
|
|
1018
|
+
async conformance(plainFile, request = {}) {
|
|
1019
|
+
const file = resolve(options.cwd, plainFile);
|
|
1020
|
+
asked.set(file, { ...asked.get(file), ...(request.model !== undefined ? { model: request.model } : {}) });
|
|
1021
|
+
const own = resolveRequiresChain(file, [], undefined, { allowNoFunctionalities: true });
|
|
1022
|
+
const self = tipOf(own);
|
|
1023
|
+
// What the person asks for comes first. Its tests are written in its own codebase, before anything is built on it:
|
|
1024
|
+
// a codebase built on it - by a module above - is disposable, and is deleted, a pulse running there stopped first;
|
|
1025
|
+
// the module above is regenerated from it once they pass. Only when there are tests to write: asked again for tests
|
|
1026
|
+
// it has, nothing is touched.
|
|
1027
|
+
const wanted = new Set([...(request.frids ?? []), ...(request.groups ?? []).flat()]);
|
|
1028
|
+
const tested = new Set((readRecord(conformanceDirFor(self.moduleName, modulesRoot(projectDirOf(self.plainFile))))?.[FUNCTIONAL_REQUIREMENTS] ?? []).map((item) => item.frid));
|
|
1029
|
+
const ownSpecs = (chainSpec(own).tree[FUNCTIONAL_REQUIREMENTS] ?? []).map((_, index) => String(index + 1));
|
|
1030
|
+
const toWrite = (wanted.size > 0 ? [...wanted] : ownSpecs).some((frid) => !tested.has(frid));
|
|
1031
|
+
let wentBack = false;
|
|
1032
|
+
if (readRecord(self.buildDir) !== null && toWrite) {
|
|
1033
|
+
const why = `${self.moduleName}'s conformance tests were asked for, and they come before anything is built on it`;
|
|
1034
|
+
for (const chain of builtTips(file)) {
|
|
1035
|
+
const above = chain.slice(chain.findIndex((entry) => entry.buildDir === self.buildDir) + 1);
|
|
1036
|
+
if (above.length === 0)
|
|
1037
|
+
continue;
|
|
1038
|
+
wentBack = true;
|
|
1039
|
+
for (const entry of above) {
|
|
1040
|
+
const running = modules.get(entry.buildDir)?.running;
|
|
1041
|
+
if (running === undefined) {
|
|
1042
|
+
if (started(entry))
|
|
1043
|
+
discard(entry, why);
|
|
1044
|
+
continue;
|
|
1045
|
+
}
|
|
1046
|
+
if (running.stopping === true && running.discard !== undefined)
|
|
1047
|
+
continue;
|
|
1048
|
+
running.stopping = true;
|
|
1049
|
+
running.discard = why;
|
|
1050
|
+
reportActivity(` ${entry.moduleName}: pulse ${running.jobId} is stopped and its codebase deleted - ${why}`);
|
|
1051
|
+
options.registry.stop(running.jobId, `Stopped because you asked for ${self.moduleName}'s conformance tests: they come before anything is built on it. ` +
|
|
1052
|
+
`${entry.moduleName}'s codebase is deleted, and it is regenerated from ${self.moduleName} once they pass.`);
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
const tracked = withParseCache(() => (wentBack ? [own] : chainsFor(file)).flatMap((chain) => toBuild(chain).map((entry) => track(chain.slice(0, chain.indexOf(entry) + 1), file, request))));
|
|
1057
|
+
// The module asked for, in its own codebase - and those below it: never the codebases built on it.
|
|
1058
|
+
const asking = tracked.find((state) => tipEntry(state).buildDir === self.buildDir);
|
|
1059
|
+
const states = asking !== undefined ? tracked.filter((state) => own.some((entry) => entry.buildDir === tipEntry(state).buildDir)) : tracked;
|
|
1060
|
+
const tip = asking ?? states.at(-1);
|
|
1061
|
+
// In groups of the person's own - 1-3 and 4-6: every functional spec of them asked for, each group a pulse of its own.
|
|
1062
|
+
const groups = request.groups?.map((group) => [...new Set(group)]).filter((group) => group.length > 0);
|
|
1063
|
+
const named = [...(request.frids ?? []), ...(groups ?? []).flat()];
|
|
1064
|
+
const asks = named.length > 0 ? [...new Set(named)] : undefined;
|
|
1065
|
+
// Its tests are written already, and a codebase built on it holds its code: nothing to write, nothing touched.
|
|
1066
|
+
if (asking === undefined && asks !== undefined && tip !== undefined) {
|
|
1067
|
+
throw new Error(`${self.moduleName}'s conformance tests for fs ${asks.join(", ")} are written already. Nothing was scheduled.`);
|
|
1068
|
+
}
|
|
1069
|
+
// Refused before anything is chosen: a functional spec the spec does not have is built by no code job, so its tests could never be written.
|
|
1070
|
+
const specs = tip !== undefined && asks !== undefined ? scheduledSpecs(tip, asks) : undefined;
|
|
1071
|
+
// On for the whole module already - asked for before, or by the config: asked again, nothing changes, and it is said.
|
|
1072
|
+
const already = new Set(states.filter((state) => {
|
|
1073
|
+
const file = tipEntry(state).plainFile;
|
|
1074
|
+
return (state.conformance !== undefined && state.conformance.chosen === undefined) || (configConformance(file) && !configChosen(file));
|
|
1075
|
+
}));
|
|
1076
|
+
// Only the module asked for: the modules below it keep their conformance tests as their config and choices have them.
|
|
1077
|
+
for (const state of tip !== undefined ? [tip] : []) {
|
|
1078
|
+
// The functional specs named are the module's own: they are scheduled in its codebase, beside those chosen before.
|
|
1079
|
+
const named = specs;
|
|
1080
|
+
const before = state.conformance;
|
|
1081
|
+
// Asked for every one before, or now: every one.
|
|
1082
|
+
const all = (before !== undefined && before.chosen === undefined) || named === undefined;
|
|
1083
|
+
state.conformance = all ? {} : { chosen: before?.chosen ?? new Set() };
|
|
1084
|
+
// Scheduled now, chosen once all of them are built - at once, those that are.
|
|
1085
|
+
state.scheduled = all ? undefined : [...(state.scheduled ?? []), { specs: named, unbuilt: named.map((touched) => touched.frid) }];
|
|
1086
|
+
// One job for all of them, or one a functional spec: as this request asks. Changed, the plan is made again.
|
|
1087
|
+
const separately = request.separately === true;
|
|
1088
|
+
if ((state.conformanceSeparately === true) !== separately)
|
|
1089
|
+
state.conformancePlan = undefined;
|
|
1090
|
+
state.conformanceSeparately = separately;
|
|
1091
|
+
if (state === tip && groups !== undefined && groups.length > 0) {
|
|
1092
|
+
state.conformanceGroups = [...(state.conformanceGroups ?? []), ...groups];
|
|
1093
|
+
state.conformancePlan = undefined;
|
|
1094
|
+
}
|
|
1095
|
+
state.conformanceHalted = undefined;
|
|
1096
|
+
state.halted = undefined;
|
|
1097
|
+
}
|
|
1098
|
+
await tick();
|
|
1099
|
+
return [...new Set(states)].map((state) => {
|
|
1100
|
+
const status = statusOf(state);
|
|
1101
|
+
if (!already.has(state))
|
|
1102
|
+
return status;
|
|
1103
|
+
const built = status.planned.some((job) => job.afterCode === true) ? "they are planned into pulses once its code is built" : "every functional spec of it is tested";
|
|
1104
|
+
return { ...status, conformanceNote: `conformance tests were on for the whole of ${status.module} already: ${built}` };
|
|
1105
|
+
});
|
|
1106
|
+
},
|
|
1107
|
+
async startOver(plainFile, request = {}) {
|
|
1108
|
+
const file = resolve(options.cwd, plainFile);
|
|
1109
|
+
const own = resolveRequiresChain(file, [], undefined, { allowNoFunctionalities: true });
|
|
1110
|
+
const rebuild = new Map();
|
|
1111
|
+
for (const chain of [...chainsFor(file), own]) {
|
|
1112
|
+
const fresh = newlyRequired(chain);
|
|
1113
|
+
for (const entry of fresh?.rebuild ?? [])
|
|
1114
|
+
rebuild.set(entry.buildDir, { entry, requires: fresh.modules });
|
|
1115
|
+
}
|
|
1116
|
+
if (rebuild.size === 0)
|
|
1117
|
+
throw new Error(`Nothing of ${tipOf(own).moduleName} has to be started over: its code is built on every module it requires.`);
|
|
1118
|
+
for (const { entry } of rebuild.values()) {
|
|
1119
|
+
if (modules.get(entry.buildDir)?.running !== undefined)
|
|
1120
|
+
throw new Error(`${entry.moduleName} has a pulse running: ask again once it is done.`);
|
|
1121
|
+
}
|
|
1122
|
+
for (const { entry, requires } of rebuild.values())
|
|
1123
|
+
startAgain(entry, `it now requires ${requires.join(", ")}, which its code was not built on`);
|
|
1124
|
+
return loop.bringUp(plainFile, request);
|
|
1125
|
+
},
|
|
1126
|
+
async fromScratch(plainFile, request = {}) {
|
|
1127
|
+
const file = resolve(options.cwd, plainFile);
|
|
1128
|
+
// Where its code lives: the tips its module is built in, or - none built yet - its own codebase.
|
|
1129
|
+
const codebases = chainsFor(file).map(tipOf);
|
|
1130
|
+
for (const entry of codebases) {
|
|
1131
|
+
if (modules.get(entry.buildDir)?.running !== undefined)
|
|
1132
|
+
throw new Error(`${entry.moduleName} has a pulse running: halt it, or ask again once it is done.`);
|
|
1133
|
+
}
|
|
1134
|
+
for (const entry of codebases)
|
|
1135
|
+
if (started(entry))
|
|
1136
|
+
startAgain(entry, "asked to be regenerated from scratch");
|
|
1137
|
+
return loop.bringUp(plainFile, request);
|
|
1138
|
+
},
|
|
1139
|
+
status() {
|
|
1140
|
+
return order.map((key) => statusOf(modules.get(key)));
|
|
1141
|
+
},
|
|
1142
|
+
planned() {
|
|
1143
|
+
const now = Date.now();
|
|
1144
|
+
let sequence = Number.MAX_SAFE_INTEGER;
|
|
1145
|
+
// The last job - running or planned - of each module, by name: what a module waiting for it waits behind.
|
|
1146
|
+
const lastOf = new Map();
|
|
1147
|
+
return order.flatMap((key) => {
|
|
1148
|
+
const state = modules.get(key);
|
|
1149
|
+
const tip = tipEntry(state);
|
|
1150
|
+
const status = statusOf(state);
|
|
1151
|
+
let before = status.running?.jobId;
|
|
1152
|
+
if (before !== undefined)
|
|
1153
|
+
lastOf.set(tip.moduleName, before);
|
|
1154
|
+
const row = (id, summary, note, waitingOn, conformance = false) => ({
|
|
1155
|
+
id,
|
|
1156
|
+
kind: conformance ? "conformance" : "regenerate",
|
|
1157
|
+
summary,
|
|
1158
|
+
module: tip.moduleName,
|
|
1159
|
+
note,
|
|
1160
|
+
...(state.model !== undefined ? { model: state.model } : {}),
|
|
1161
|
+
status: "queued",
|
|
1162
|
+
sequence: sequence--,
|
|
1163
|
+
queuedAt: now,
|
|
1164
|
+
startedAt: now,
|
|
1165
|
+
waitingOn,
|
|
1166
|
+
tail: "",
|
|
1167
|
+
activity: [],
|
|
1168
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 },
|
|
1169
|
+
agents: [],
|
|
1170
|
+
transcript: [],
|
|
1171
|
+
});
|
|
1172
|
+
// Waiting for the module below it: not planned yet, but in the queue all the same - behind what it waits for.
|
|
1173
|
+
if (state.running === undefined && state.waitsFor !== undefined) {
|
|
1174
|
+
const behind = lastOf.get(state.waitsFor);
|
|
1175
|
+
const id = `planned-${tip.moduleName}-waiting`;
|
|
1176
|
+
lastOf.set(tip.moduleName, id);
|
|
1177
|
+
return [
|
|
1178
|
+
row(id, `${basename(tip.plainFile)} - waits for ${state.waitsFor}`, `Not planned yet: its work is planned into pulses once ${state.waitsFor} is built - the code it is built on.`, behind !== undefined ? [behind] : []),
|
|
1179
|
+
];
|
|
1180
|
+
}
|
|
1181
|
+
return status.planned.map((job, index) => {
|
|
1182
|
+
const planned = row(`planned-${tip.moduleName}-${index + 1}`, `${basename(tip.plainFile)} ${job.track === "conformance" ? "conformance: " : ""}${job.title}`, job.waitsFor !== undefined
|
|
1183
|
+
? `Scheduled, not started: the conformance tests of ${job.title}, once ${waitsText(job.waitsFor)} built - the code record has to have them first.`
|
|
1184
|
+
: job.afterCode === true
|
|
1185
|
+
? "Not planned yet: the module's conformance tests are planned into pulses once its code is built."
|
|
1186
|
+
: job.planning === true
|
|
1187
|
+
? `Being planned into pulses now: ${job.title}. The first starts as soon as the plan is made.`
|
|
1188
|
+
: `Planned, not started: ${casesText(job.cases)}. It is planned again whenever the spec changes or a pulse before it ends.`, before !== undefined ? [before] : [], job.track === "conformance");
|
|
1189
|
+
if (job.planning === true)
|
|
1190
|
+
planned.planning = { since: state.planningSince ?? now };
|
|
1191
|
+
before = planned.id;
|
|
1192
|
+
lastOf.set(tip.moduleName, planned.id);
|
|
1193
|
+
return planned;
|
|
1194
|
+
});
|
|
1195
|
+
});
|
|
1196
|
+
},
|
|
1197
|
+
firsts() {
|
|
1198
|
+
return order.flatMap((key) => {
|
|
1199
|
+
const first = firsts.get(key);
|
|
1200
|
+
const tip = tipEntry(modules.get(key));
|
|
1201
|
+
return first === undefined ? [] : [{ module: tip.moduleName, plainFile: tip.plainFile, buildDir: tip.buildDir, ...first }];
|
|
1202
|
+
});
|
|
1203
|
+
},
|
|
1204
|
+
mapPlan() {
|
|
1205
|
+
return {
|
|
1206
|
+
files: [...asked.keys()],
|
|
1207
|
+
codebases: order.map((key) => {
|
|
1208
|
+
const state = modules.get(key);
|
|
1209
|
+
const tip = tipEntry(state);
|
|
1210
|
+
const { jobs, scheduled } = describe(state);
|
|
1211
|
+
const running = state.running;
|
|
1212
|
+
let next = running?.number ?? state.pulses ?? 0;
|
|
1213
|
+
const pulses = [
|
|
1214
|
+
...(running !== undefined
|
|
1215
|
+
? [{ number: running.number, title: running.planned.title, track: running.planned.track, running: true, rowId: running.jobId, before: serializeTree(running.from), after: serializeTree(running.after) }]
|
|
1216
|
+
: []),
|
|
1217
|
+
...jobs.flatMap((job, index) => {
|
|
1218
|
+
const rowId = `planned-${tip.moduleName}-${index + 1}`;
|
|
1219
|
+
// Scheduled ahead of its code: numbered among the rest, its functional specs by their text.
|
|
1220
|
+
const ahead = scheduled[index];
|
|
1221
|
+
if (ahead !== undefined)
|
|
1222
|
+
return [{ number: (next += 1), title: ahead.title, track: "conformance", running: false, rowId, before: "", after: "", specs: ahead.specs }];
|
|
1223
|
+
return job === undefined ? [] : [{ number: (next += 1), title: job.title, track: job.track, running: false, rowId, before: job.step.before, after: job.step.after }];
|
|
1224
|
+
}),
|
|
1225
|
+
];
|
|
1226
|
+
const gaveUp = running === undefined && state.why === NO_PLAN ? { why: NO_PLAN } : undefined;
|
|
1227
|
+
const stuck = running === undefined && state.halted !== undefined ? { why: state.halted.why, ...(state.halted.pulse !== undefined ? { pulse: state.halted.pulse } : {}), ...(state.halted.cause !== undefined ? { cause: state.halted.cause } : {}) } : gaveUp;
|
|
1228
|
+
return {
|
|
1229
|
+
module: tip.moduleName,
|
|
1230
|
+
plainFile: tip.plainFile,
|
|
1231
|
+
buildDir: tip.buildDir,
|
|
1232
|
+
pulses,
|
|
1233
|
+
conformance: state.conformance !== undefined || configConformance(tip.plainFile),
|
|
1234
|
+
...(state.planning !== undefined ? { planning: state.planning } : {}),
|
|
1235
|
+
...(running === undefined && state.waitsFor !== undefined ? { waitsFor: state.waitsFor } : {}),
|
|
1236
|
+
...(running === undefined && state.why !== undefined ? { why: state.why } : {}),
|
|
1237
|
+
...(stuck !== undefined ? { stuck } : {}),
|
|
1238
|
+
};
|
|
1239
|
+
}),
|
|
1240
|
+
};
|
|
1241
|
+
},
|
|
1242
|
+
async drain() {
|
|
1243
|
+
for (;;) {
|
|
1244
|
+
await ticking;
|
|
1245
|
+
const running = order.map((key) => modules.get(key).running?.jobId).filter((id) => id !== undefined);
|
|
1246
|
+
if (running.length === 0)
|
|
1247
|
+
return;
|
|
1248
|
+
await Promise.all(running.map((id) => options.registry.wait(id).catch(() => undefined)));
|
|
1249
|
+
// Its settling ticks: let it start what comes next before looking again.
|
|
1250
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
1251
|
+
}
|
|
1252
|
+
},
|
|
1253
|
+
};
|
|
1254
|
+
return loop;
|
|
1255
|
+
}
|
|
1256
|
+
/** Why nothing runs in a codebase whose code - and conformance tests, where they are wanted - is built from its spec as it is. */
|
|
1257
|
+
export const UP_TO_DATE = "built from the spec as it is";
|
|
1258
|
+
/** Why a codebase with work left has no plan: the planner found none. */
|
|
1259
|
+
const NO_PLAN = "the planner found no way to cut the work into pulses that hold together";
|
|
1260
|
+
/** A diff's lines placed in the .plain files of a chain - the tip's first - named as they are from the project. */
|
|
1261
|
+
function whereInFiles(diff, chain) {
|
|
1262
|
+
try {
|
|
1263
|
+
const files = [...chain].reverse().flatMap((entry) => specFilesOf(entry.plainFile));
|
|
1264
|
+
// A standard library template by its import path, not by where turri happens to be.
|
|
1265
|
+
const nameOf = (path) => (path.startsWith(`${STDLIB_ROOT}/stdlib/`) ? relative(STDLIB_ROOT, path) : relative(projectDirOf(chain.at(-1).plainFile), path) || basename(path));
|
|
1266
|
+
const unique = [...new Set(files)].map((path) => ({ name: nameOf(path), text: readFileSync(path, "utf8") }));
|
|
1267
|
+
return locateDiffLines(diff, unique);
|
|
1268
|
+
}
|
|
1269
|
+
catch {
|
|
1270
|
+
return {};
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
/** The functional specs a scheduled conformance job waits for: "fs 2 is", "fs 2, fs 3 are". */
|
|
1274
|
+
function waitsText(frids) {
|
|
1275
|
+
return `${frids.map((frid) => `fs ${frid}`).join(", ")} ${frids.length === 1 ? "is" : "are"}`;
|
|
1276
|
+
}
|
|
1277
|
+
/** What a job does, in a few words: its cases. */
|
|
1278
|
+
function casesText(cases) {
|
|
1279
|
+
const parts = [
|
|
1280
|
+
...(cases.fresh ? ["sets the project up"] : []),
|
|
1281
|
+
...(cases.add.length > 0 ? [`adds ${cases.add.map((spec) => `fs ${spec.frid}`).join(", ")}`] : []),
|
|
1282
|
+
...(cases.patch.length > 0 ? [`changes ${cases.patch.map((spec) => `fs ${spec.frid}`).join(", ")}`] : []),
|
|
1283
|
+
...(cases.again.length > 0 ? [`regenerates ${cases.again.map((spec) => `fs ${spec.frid}`).join(", ")} again`] : []),
|
|
1284
|
+
...(cases.delete.length > 0 ? [`takes out ${cases.delete.map((spec) => `fs ${spec.frid}`).join(", ")}`] : []),
|
|
1285
|
+
];
|
|
1286
|
+
return parts.length > 0 ? parts.join(", ") : "no functional spec - reqs or definitions only";
|
|
1287
|
+
}
|
|
1288
|
+
/** The codebases the loop looks after, as the person reads them: what runs, what is planned after it, and why nothing does. */
|
|
1289
|
+
/**
|
|
1290
|
+
* The coding agent's task for a dry run's first job, built as the render builds it - and all it did rolled back. Not for
|
|
1291
|
+
* a conformance job: its prompt is built from what the code holds once it is built.
|
|
1292
|
+
*/
|
|
1293
|
+
export async function promptOf(first) {
|
|
1294
|
+
if (first.planned.track !== "code")
|
|
1295
|
+
return undefined;
|
|
1296
|
+
const result = await renderSpecDiff({
|
|
1297
|
+
plainFile: first.plainFile,
|
|
1298
|
+
diff: first.planned.step.textDiff,
|
|
1299
|
+
applied: "already applied",
|
|
1300
|
+
spec: first.spec,
|
|
1301
|
+
cases: first.planned.cases,
|
|
1302
|
+
dryRun: true,
|
|
1303
|
+
});
|
|
1304
|
+
return result.task;
|
|
1305
|
+
}
|
|
1306
|
+
/**
|
|
1307
|
+
* What bringing a spec's module up would start, in each codebase, and the prompt of each - planned on a loop of its own
|
|
1308
|
+
* that starts nothing. A codebase `busy` says is running a job is left out: a dry run starts from its last commit, and
|
|
1309
|
+
* what the next job there is given depends on what the running one lands.
|
|
1310
|
+
*/
|
|
1311
|
+
export async function previewJobs(options) {
|
|
1312
|
+
const loop = createWorkLoop({
|
|
1313
|
+
registry: createJobRegistry(),
|
|
1314
|
+
cwd: options.cwd,
|
|
1315
|
+
dryRun: true,
|
|
1316
|
+
...(options.buildDir !== undefined ? { buildDir: options.buildDir } : {}),
|
|
1317
|
+
...(options.model !== undefined ? { modelFor: () => options.model } : {}),
|
|
1318
|
+
...(options.plannerModel !== undefined ? { plannerModel: options.plannerModel } : {}),
|
|
1319
|
+
...(options.steering !== undefined ? { steering: options.steering } : {}),
|
|
1320
|
+
});
|
|
1321
|
+
const statuses = await loop.bringUp(options.plainFile, options.model !== undefined ? { model: options.model } : {});
|
|
1322
|
+
const previews = [];
|
|
1323
|
+
const busy = [];
|
|
1324
|
+
for (const first of loop.firsts()) {
|
|
1325
|
+
if (options.busy?.(first.module) === true) {
|
|
1326
|
+
busy.push(first.module);
|
|
1327
|
+
continue;
|
|
1328
|
+
}
|
|
1329
|
+
const preview = { module: first.module, title: first.planned.title, track: first.planned.track, cases: first.planned.cases };
|
|
1330
|
+
const task = await promptOf(first);
|
|
1331
|
+
if (task !== undefined)
|
|
1332
|
+
preview.task = task;
|
|
1333
|
+
previews.push(preview);
|
|
1334
|
+
}
|
|
1335
|
+
return { statuses, previews, busy };
|
|
1336
|
+
}
|
|
1337
|
+
/** Nothing runs, is planned or is waiting there, as its code is built from its spec as it is. */
|
|
1338
|
+
export function isUpToDate(status) {
|
|
1339
|
+
return status.why === UP_TO_DATE && status.running === undefined && status.planned.length === 0 && status.startOver === undefined;
|
|
1340
|
+
}
|
|
1341
|
+
export function describeModules(statuses, options = {}) {
|
|
1342
|
+
if (statuses.length === 0)
|
|
1343
|
+
return "Nothing is being built.";
|
|
1344
|
+
const startOver = (status) => {
|
|
1345
|
+
if (status.startOver === undefined)
|
|
1346
|
+
return [];
|
|
1347
|
+
const { requires, codebases } = status.startOver;
|
|
1348
|
+
return options.for === "command line"
|
|
1349
|
+
? [` turri regenerate ${basename(status.plainFile)} --start-over starts ${codebases.join(", ")} over`]
|
|
1350
|
+
: [
|
|
1351
|
+
` Ask the user whether to start ${codebases.join(", ")} over, and say why: ${codebases.join(", ")} was built without ` +
|
|
1352
|
+
`${requires.join(", ")}, so no change to its code can bring ${requires.join(", ")} in - its code and conformance ` +
|
|
1353
|
+
"tests are taken out, kept in its history, and built again from the bottom of the chain. Only if they agree, " +
|
|
1354
|
+
`call regenerate with ${basename(status.plainFile)} and from_scratch.`,
|
|
1355
|
+
];
|
|
1356
|
+
};
|
|
1357
|
+
return statuses
|
|
1358
|
+
.map((status) => {
|
|
1359
|
+
// Nothing to do there: said in a sentence, not as a status.
|
|
1360
|
+
if (isUpToDate(status))
|
|
1361
|
+
return `${status.module} is up to date: its code is built from the spec as it is now in ${basename(status.plainFile)}, so there is nothing to regenerate.`;
|
|
1362
|
+
const lines = [`${status.module} (${basename(status.plainFile)}):`];
|
|
1363
|
+
if (status.running !== undefined) {
|
|
1364
|
+
lines.push(` running: pulse ${status.running.jobId} - ${status.running.title}`);
|
|
1365
|
+
// Steered, not stopped or planned again: what changed went to its agent.
|
|
1366
|
+
for (const why of status.running.steered ?? [])
|
|
1367
|
+
lines.push(` ⇢ steered: ${why} - its agent was told, and it lands the spec as it is now there`);
|
|
1368
|
+
}
|
|
1369
|
+
status.planned.forEach((job, index) => lines.push(job.afterCode === true
|
|
1370
|
+
? ` ${index + 1}. conformance tests - planned once the code is built`
|
|
1371
|
+
: job.waitsFor !== undefined
|
|
1372
|
+
? ` ${index + 1}. conformance tests: ${job.title} - scheduled, starts once ${waitsText(job.waitsFor)} built`
|
|
1373
|
+
: job.planning === true
|
|
1374
|
+
? ` ${index + 1}. ${job.track === "conformance" ? "conformance tests: " : ""}${job.title} - being planned into pulses`
|
|
1375
|
+
: ` ${index + 1}. ${job.track === "conformance" ? "conformance tests: " : ""}${job.title} - ${casesText(job.cases)}`));
|
|
1376
|
+
if (status.planNotes !== undefined)
|
|
1377
|
+
lines.push(" what the plan did not do as asked:", ...status.planNotes.map((note) => ` - ${note}`));
|
|
1378
|
+
if (status.why !== undefined)
|
|
1379
|
+
lines.push(` ${status.why}`);
|
|
1380
|
+
if (status.conformanceNote !== undefined)
|
|
1381
|
+
lines.push(` ${status.conformanceNote}`);
|
|
1382
|
+
lines.push(...startOver(status));
|
|
1383
|
+
return lines.join("\n");
|
|
1384
|
+
})
|
|
1385
|
+
.join("\n");
|
|
1386
|
+
}
|
|
1387
|
+
/**
|
|
1388
|
+
* A conformance job: its tests written and run for its change - a failure judged, the test or the code fixed - then the
|
|
1389
|
+
* whole suite, the tests the module inherits with it. Passing, what the tests are written for is recorded and committed;
|
|
1390
|
+
* otherwise all of it - the code's fixes too - is undone.
|
|
1391
|
+
*/
|
|
1392
|
+
const defaultConformanceRunner = async (job, onDelta) => {
|
|
1393
|
+
const result = await renderSpecDiffConformance({
|
|
1394
|
+
plainFile: job.plainFile,
|
|
1395
|
+
diff: job.planned.step.textDiff,
|
|
1396
|
+
change: describeSpecChange(job.planned.step.changes, job.moduleName),
|
|
1397
|
+
cwd: job.cwd,
|
|
1398
|
+
applied: "already applied",
|
|
1399
|
+
spec: job.spec,
|
|
1400
|
+
cases: job.planned.cases,
|
|
1401
|
+
...(job.model !== undefined ? { model: job.model } : {}),
|
|
1402
|
+
onDelta,
|
|
1403
|
+
});
|
|
1404
|
+
if (result.passed) {
|
|
1405
|
+
writeTestsRecord(result.conformanceDir, job.planned.step.tree);
|
|
1406
|
+
recordHistory(job.buildDir, `conformance tests: ${job.planned.title}`);
|
|
1407
|
+
}
|
|
1408
|
+
return { landed: result.passed, report: describeSpecDiffConformance(result) };
|
|
1409
|
+
};
|
|
1410
|
+
/** A job as a spec diff render, landed on its unit tests: conformance is a track of its own. */
|
|
1411
|
+
const defaultRunner = async (job, onDelta) => {
|
|
1412
|
+
const result = await renderSpecDiff({
|
|
1413
|
+
plainFile: job.plainFile,
|
|
1414
|
+
diff: job.planned.step.textDiff,
|
|
1415
|
+
cwd: job.cwd,
|
|
1416
|
+
applied: "already applied",
|
|
1417
|
+
spec: job.spec,
|
|
1418
|
+
cases: job.planned.cases,
|
|
1419
|
+
...(job.model !== undefined ? { model: job.model } : {}),
|
|
1420
|
+
...(job.steering !== undefined ? { steering: job.steering } : {}),
|
|
1421
|
+
onDelta,
|
|
1422
|
+
});
|
|
1423
|
+
// Landed code can be tried.
|
|
1424
|
+
return { landed: result.passed, report: withTryItLine(describeSpecDiff(result), result.passed && result.restructured !== true), story: codeStory(result) };
|
|
1425
|
+
};
|
|
1426
|
+
/** How a code pulse went, for its line in the notes: the rounds it took, its test runs, what the debugger found. */
|
|
1427
|
+
export function codeStory(result) {
|
|
1428
|
+
if (result.restructured === true)
|
|
1429
|
+
return "only restructured, nothing regenerated";
|
|
1430
|
+
const attempts = result.attempts ?? 0;
|
|
1431
|
+
const runs = result.runs?.length ?? 0;
|
|
1432
|
+
const rounds = `${attempts} round${attempts === 1 ? "" : "s"} of the unit tests, ${runs} test run${runs === 1 ? "" : "s"}`;
|
|
1433
|
+
const found = (result.diagnosed ?? []).map((cause) => `the debugger: ${cause}`);
|
|
1434
|
+
return [rounds, ...found].join("; ");
|
|
1435
|
+
}
|