@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,527 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { ACCEPTANCE_TESTS, DEFINITIONS, FUNCTIONAL_REQUIREMENTS, NON_FUNCTIONAL_REQUIREMENTS, REQUIRED_FUNCTIONAL_SPECS, TEST_REQUIREMENTS, getInheritedFunctionalSpecs, } from "../plain/spec.js";
|
|
6
|
+
import { validateConcepts } from "../plain/concepts.js";
|
|
7
|
+
import { sectionTag } from "./prompt.js";
|
|
8
|
+
/**
|
|
9
|
+
* The steps from one spec to another: a sequence of spec diffs that, rendered one after the
|
|
10
|
+
* other, take code built from the first spec to code built from the second.
|
|
11
|
+
*
|
|
12
|
+
* Both specs are marshalled trees, written out as text with one JSON value per line - a
|
|
13
|
+
* section's name, then each of its items on a line of its own - and compared with git. So an
|
|
14
|
+
* item is the unit of change: a functional spec with its acceptance tests and linked resources,
|
|
15
|
+
* a definition, a req. Git lines them up, so a spec inserted in the middle is one addition and
|
|
16
|
+
* the specs after it are untouched, though their numbers moved.
|
|
17
|
+
*
|
|
18
|
+
* The planner (`chunk-plan.ts`) cuts the changes into steps - which go together, which go first. It
|
|
19
|
+
* picks changes by id; it never writes a diff. `makeStep` applies what it picked, and `inconsistencies`
|
|
20
|
+
* checks that each step leaves a spec that holds together - no concept used before a step defines it.
|
|
21
|
+
*/
|
|
22
|
+
/** The sections of a tree, in the order they are written. Written even when empty, so they never change. */
|
|
23
|
+
export const TREE_SECTIONS = [DEFINITIONS, NON_FUNCTIONAL_REQUIREMENTS, TEST_REQUIREMENTS, FUNCTIONAL_REQUIREMENTS, REQUIRED_FUNCTIONAL_SPECS];
|
|
24
|
+
function sortKeys(value) {
|
|
25
|
+
if (Array.isArray(value))
|
|
26
|
+
return value.map(sortKeys);
|
|
27
|
+
if (value !== null && typeof value === "object") {
|
|
28
|
+
return Object.fromEntries(Object.keys(value)
|
|
29
|
+
.sort()
|
|
30
|
+
.map((key) => [key, sortKeys(value[key])]));
|
|
31
|
+
}
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* A tree as text: each section's name on a line - `["definitions"]` - then one JSON value per
|
|
36
|
+
* item, keys sorted. No commas and no brackets around a section, so adding an item at the end
|
|
37
|
+
* changes nothing before it.
|
|
38
|
+
*/
|
|
39
|
+
export function serializeTree(tree) {
|
|
40
|
+
const others = Object.keys(tree)
|
|
41
|
+
.filter((key) => !TREE_SECTIONS.includes(key))
|
|
42
|
+
.sort();
|
|
43
|
+
const lines = [];
|
|
44
|
+
for (const key of [...TREE_SECTIONS, ...others]) {
|
|
45
|
+
const value = tree[key];
|
|
46
|
+
if (value !== undefined && !Array.isArray(value)) {
|
|
47
|
+
// Not a list of items: one value, on its own line.
|
|
48
|
+
lines.push(JSON.stringify([key, "value"]), JSON.stringify(sortKeys(value)));
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (value === undefined && !TREE_SECTIONS.includes(key))
|
|
52
|
+
continue;
|
|
53
|
+
lines.push(JSON.stringify([key]));
|
|
54
|
+
for (const item of value ?? [])
|
|
55
|
+
lines.push(JSON.stringify(sortKeys(item)));
|
|
56
|
+
}
|
|
57
|
+
return `${lines.join("\n")}\n`;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Whether two trees are the same spec, however it is laid out across files: the definitions, the
|
|
61
|
+
* implementation and test reqs the same - in any order, from the module's own file or an imported
|
|
62
|
+
* one - and the functional specs the same, in order, those it inherits then its own, whichever
|
|
63
|
+
* module each is written in. Specs moved between a module and the ones it requires are the same
|
|
64
|
+
* spec: the code holds them all. But a module required now that was not before is not - its code
|
|
65
|
+
* is not in this codebase - so `after` may only drop required modules, never gain one.
|
|
66
|
+
*
|
|
67
|
+
* What it takes to tell a restructuring of the specs - definitions moved to a common module,
|
|
68
|
+
* sections reordered - from a change: one of these has nothing to render.
|
|
69
|
+
*/
|
|
70
|
+
export function sameSpec(before, after) {
|
|
71
|
+
const modulesOf = (tree) => new Set(getInheritedFunctionalSpecs(tree).map((spec) => spec.module));
|
|
72
|
+
const had = modulesOf(before);
|
|
73
|
+
if ([...modulesOf(after)].some((module) => !had.has(module)))
|
|
74
|
+
return false;
|
|
75
|
+
const text = (item) => {
|
|
76
|
+
const { module: _module, frid: _frid, ...rest } = item;
|
|
77
|
+
return JSON.stringify(sortKeys(rest));
|
|
78
|
+
};
|
|
79
|
+
const list = (tree, heading) => (tree[heading] ?? []).map(text);
|
|
80
|
+
const shape = (tree) => {
|
|
81
|
+
const others = Object.keys(tree)
|
|
82
|
+
.filter((key) => !TREE_SECTIONS.includes(key))
|
|
83
|
+
.sort()
|
|
84
|
+
.map((key) => [key, sortKeys(tree[key])]);
|
|
85
|
+
return JSON.stringify({
|
|
86
|
+
sections: [DEFINITIONS, NON_FUNCTIONAL_REQUIREMENTS, TEST_REQUIREMENTS].map((heading) => list(tree, heading).sort()),
|
|
87
|
+
specs: [...list(tree, REQUIRED_FUNCTIONAL_SPECS), ...list(tree, FUNCTIONAL_REQUIREMENTS)],
|
|
88
|
+
others,
|
|
89
|
+
});
|
|
90
|
+
};
|
|
91
|
+
return shape(before) === shape(after);
|
|
92
|
+
}
|
|
93
|
+
/** Whether a line of a serialized tree is a section's name rather than an item. */
|
|
94
|
+
function headerOf(line) {
|
|
95
|
+
if (!line.startsWith("["))
|
|
96
|
+
return undefined;
|
|
97
|
+
const parsed = JSON.parse(line);
|
|
98
|
+
return { key: parsed[0], value: parsed[1] === "value" };
|
|
99
|
+
}
|
|
100
|
+
/** A serialized tree read back. A known section with no items is left out, as the parser leaves it. */
|
|
101
|
+
export function parseTree(text) {
|
|
102
|
+
const tree = {};
|
|
103
|
+
let section;
|
|
104
|
+
let single = false;
|
|
105
|
+
for (const line of text.split("\n")) {
|
|
106
|
+
if (line === "")
|
|
107
|
+
continue;
|
|
108
|
+
const header = headerOf(line);
|
|
109
|
+
if (header !== undefined) {
|
|
110
|
+
section = header.key;
|
|
111
|
+
single = header.value;
|
|
112
|
+
if (!single && !TREE_SECTIONS.includes(section))
|
|
113
|
+
tree[section] = [];
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (section === undefined)
|
|
117
|
+
throw new Error(`An item before any section: ${line}`);
|
|
118
|
+
const item = JSON.parse(line);
|
|
119
|
+
if (single)
|
|
120
|
+
tree[section] = item;
|
|
121
|
+
else
|
|
122
|
+
tree[section] = [...(tree[section] ?? []), item];
|
|
123
|
+
}
|
|
124
|
+
return tree;
|
|
125
|
+
}
|
|
126
|
+
/** git's diff of two serialized trees, with `context` lines around each change. */
|
|
127
|
+
export function diffTrees(before, after, context = 3) {
|
|
128
|
+
return gitDiff(before, after, context, ["spec.before", "spec.after"]);
|
|
129
|
+
}
|
|
130
|
+
/** git's diff of two texts, written to files of the names given. */
|
|
131
|
+
function gitDiff(before, after, context, [from, to]) {
|
|
132
|
+
const dir = mkdtempSync(join(tmpdir(), "turri-spec-steps-"));
|
|
133
|
+
try {
|
|
134
|
+
// The names are paths - "plain/hubspot.plain" for a spec in a folder of the project: their folders first.
|
|
135
|
+
mkdirSync(dirname(join(dir, "a", from)), { recursive: true });
|
|
136
|
+
mkdirSync(dirname(join(dir, "b", to)), { recursive: true });
|
|
137
|
+
writeFileSync(join(dir, "a", from), before);
|
|
138
|
+
writeFileSync(join(dir, "b", to), after);
|
|
139
|
+
const run = spawnSync("git", ["diff", "--no-index", "--no-color", "--no-ext-diff", "--no-prefix", `-U${context}`, `a/${from}`, `b/${to}`], { cwd: dir, encoding: "utf8" });
|
|
140
|
+
// 1 means the two differ: that is the diff, not a failure.
|
|
141
|
+
if (run.status !== 0 && run.status !== 1)
|
|
142
|
+
throw new Error(`git diff failed: ${run.stderr}`);
|
|
143
|
+
// As a spec diff written in the interview reads: no index line, and no heading git guesses
|
|
144
|
+
// for a hunk - in a .plain file that is whatever line above it starts with a letter.
|
|
145
|
+
return run.stdout
|
|
146
|
+
.split("\n")
|
|
147
|
+
.filter((line) => !line.startsWith("index "))
|
|
148
|
+
.map((line) => line.replace(/^(@@ -\S+ \+\S+ @@).*$/, "$1"))
|
|
149
|
+
.join("\n");
|
|
150
|
+
}
|
|
151
|
+
finally {
|
|
152
|
+
rmSync(dir, { recursive: true, force: true });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/** Which section each line of a serialized tree is in, and each line's number within it. */
|
|
156
|
+
function sectionsOfLines(lines) {
|
|
157
|
+
let section = "";
|
|
158
|
+
let index = 0;
|
|
159
|
+
return lines.map((line) => {
|
|
160
|
+
const header = headerOf(line);
|
|
161
|
+
if (header !== undefined) {
|
|
162
|
+
section = header.key;
|
|
163
|
+
index = 0;
|
|
164
|
+
return { section, index: 0 };
|
|
165
|
+
}
|
|
166
|
+
index += 1;
|
|
167
|
+
return { section, index };
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
/** The changes that take one serialized tree to another, in the order git lists them. */
|
|
171
|
+
export function changesBetween(before, after) {
|
|
172
|
+
const lines = before.split("\n").slice(0, -1);
|
|
173
|
+
const where = sectionsOfLines(lines);
|
|
174
|
+
const whereAfter = sectionsOfLines(after.split("\n").slice(0, -1));
|
|
175
|
+
const changes = [];
|
|
176
|
+
let order = 0;
|
|
177
|
+
const hunks = diffTrees(before, after, 0).split("\n");
|
|
178
|
+
for (let i = 0; i < hunks.length; i += 1) {
|
|
179
|
+
const match = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,\d+)? @@/.exec(hunks[i]);
|
|
180
|
+
if (match === null)
|
|
181
|
+
continue;
|
|
182
|
+
const start = Number(match[1]);
|
|
183
|
+
const count = match[2] === undefined ? 1 : Number(match[2]);
|
|
184
|
+
// Where the added lines are in the target: from its line numbers, their numbers in their sections.
|
|
185
|
+
const addedFrom = Number(match[3]) - 1;
|
|
186
|
+
// git numbers from 1, and an empty side names the line before it.
|
|
187
|
+
const first = count === 0 ? start : start - 1;
|
|
188
|
+
const removed = [];
|
|
189
|
+
const added = [];
|
|
190
|
+
for (i += 1; i < hunks.length && !hunks[i].startsWith("@@"); i += 1) {
|
|
191
|
+
const line = hunks[i];
|
|
192
|
+
if (line.startsWith("-"))
|
|
193
|
+
removed.push(line.slice(1));
|
|
194
|
+
else if (line.startsWith("+"))
|
|
195
|
+
added.push(line.slice(1));
|
|
196
|
+
}
|
|
197
|
+
i -= 1;
|
|
198
|
+
// An item replaced by one like it is a change to it; one replaced by something else is a
|
|
199
|
+
// removal and an addition - a spec taken out and another put where it was are two changes,
|
|
200
|
+
// not one. Pairs keep their order on both sides, and a section's name is never paired.
|
|
201
|
+
const pairs = pairItems(removed, added);
|
|
202
|
+
const pairOfRemoved = new Map(pairs.map(([r, a]) => [r, a]));
|
|
203
|
+
const pairedAdded = new Set(pairs.map(([, a]) => a));
|
|
204
|
+
// Each change in the order git lists the lines: removals, then what is added. An addition
|
|
205
|
+
// goes in front of the line of the next pair after it - where it sits in the target - or,
|
|
206
|
+
// with none, after the last removed line.
|
|
207
|
+
const nextId = () => `c${changes.length + 1}`;
|
|
208
|
+
const end = first + removed.length;
|
|
209
|
+
const anchorOf = (a) => {
|
|
210
|
+
const next = pairs.find(([, pa]) => pa > a);
|
|
211
|
+
return next === undefined ? end : first + next[0];
|
|
212
|
+
};
|
|
213
|
+
const orderOf = new Map(added.map((_, a) => [a, order++]));
|
|
214
|
+
removed.forEach((line, r) => {
|
|
215
|
+
const at = first + r;
|
|
216
|
+
const a = pairOfRemoved.get(r);
|
|
217
|
+
if (a !== undefined) {
|
|
218
|
+
changes.push({
|
|
219
|
+
id: nextId(),
|
|
220
|
+
section: where[at].section,
|
|
221
|
+
kind: "change",
|
|
222
|
+
before: line,
|
|
223
|
+
after: added[a],
|
|
224
|
+
position: where[at].index,
|
|
225
|
+
removes: at,
|
|
226
|
+
inserts: { at, order: orderOf.get(a) },
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
else {
|
|
230
|
+
changes.push({ id: nextId(), section: where[at].section, kind: "remove", before: line, position: where[at].index, removes: at });
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
added.forEach((line, a) => {
|
|
234
|
+
if (pairedAdded.has(a))
|
|
235
|
+
return;
|
|
236
|
+
const at = anchorOf(a);
|
|
237
|
+
const landing = whereAfter[addedFrom + a];
|
|
238
|
+
changes.push({
|
|
239
|
+
id: nextId(),
|
|
240
|
+
section: landing.section,
|
|
241
|
+
kind: "add",
|
|
242
|
+
after: line,
|
|
243
|
+
position: landing.index,
|
|
244
|
+
inserts: { at, order: orderOf.get(a) },
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
return changes;
|
|
249
|
+
}
|
|
250
|
+
/** The words of an item's text, for telling a changed item from a different one. */
|
|
251
|
+
function wordsOf(line) {
|
|
252
|
+
const item = JSON.parse(line);
|
|
253
|
+
return new Set((item.markdown ?? line).toLowerCase().match(/[a-z0-9_]+/g) ?? []);
|
|
254
|
+
}
|
|
255
|
+
/** How alike two items are: the share of their words they have in common. */
|
|
256
|
+
export function similarity(a, b) {
|
|
257
|
+
const x = wordsOf(a);
|
|
258
|
+
const y = wordsOf(b);
|
|
259
|
+
if (x.size === 0 && y.size === 0)
|
|
260
|
+
return 1;
|
|
261
|
+
let shared = 0;
|
|
262
|
+
for (const word of x)
|
|
263
|
+
if (y.has(word))
|
|
264
|
+
shared += 1;
|
|
265
|
+
return shared / (x.size + y.size - shared);
|
|
266
|
+
}
|
|
267
|
+
/** Alike enough to be the same item, changed. */
|
|
268
|
+
const SAME_ITEM = 0.5;
|
|
269
|
+
/**
|
|
270
|
+
* Which removed item became which added one, in order on both sides: the pairing that matches
|
|
271
|
+
* the most alike items. Only items at least SAME_ITEM alike are paired.
|
|
272
|
+
*/
|
|
273
|
+
function pairItems(removed, added) {
|
|
274
|
+
const alike = (r, a) => headerOf(removed[r]) === undefined && headerOf(added[a]) === undefined && similarity(removed[r], added[a]) >= SAME_ITEM
|
|
275
|
+
? similarity(removed[r], added[a])
|
|
276
|
+
: 0;
|
|
277
|
+
// Best total likeness over order-keeping pairings - a longest common subsequence, weighted.
|
|
278
|
+
const best = Array.from({ length: removed.length + 1 }, () => new Array(added.length + 1).fill(0));
|
|
279
|
+
for (let r = removed.length - 1; r >= 0; r -= 1) {
|
|
280
|
+
for (let a = added.length - 1; a >= 0; a -= 1) {
|
|
281
|
+
const score = alike(r, a);
|
|
282
|
+
best[r][a] = Math.max(best[r + 1][a], best[r][a + 1], score > 0 ? score + best[r + 1][a + 1] : 0);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
const pairs = [];
|
|
286
|
+
for (let r = 0, a = 0; r < removed.length && a < added.length;) {
|
|
287
|
+
const score = alike(r, a);
|
|
288
|
+
if (score > 0 && best[r][a] === score + best[r + 1][a + 1]) {
|
|
289
|
+
pairs.push([r, a]);
|
|
290
|
+
r += 1;
|
|
291
|
+
a += 1;
|
|
292
|
+
}
|
|
293
|
+
else if (best[r][a] === best[r + 1][a])
|
|
294
|
+
r += 1;
|
|
295
|
+
else
|
|
296
|
+
a += 1;
|
|
297
|
+
}
|
|
298
|
+
return pairs;
|
|
299
|
+
}
|
|
300
|
+
/** A selection of changes, applied to the serialized tree they were found in. */
|
|
301
|
+
export function applyChanges(before, changes) {
|
|
302
|
+
const lines = before.split("\n").slice(0, -1);
|
|
303
|
+
const removed = new Set(changes.flatMap((change) => (change.removes === undefined ? [] : [change.removes])));
|
|
304
|
+
const inserts = changes
|
|
305
|
+
.filter((change) => change.inserts !== undefined)
|
|
306
|
+
.sort((a, b) => a.inserts.order - b.inserts.order);
|
|
307
|
+
const out = [];
|
|
308
|
+
for (let at = 0; at <= lines.length; at += 1) {
|
|
309
|
+
for (const change of inserts)
|
|
310
|
+
if (change.inserts.at === at)
|
|
311
|
+
out.push(change.after);
|
|
312
|
+
if (at < lines.length && !removed.has(at))
|
|
313
|
+
out.push(lines[at]);
|
|
314
|
+
}
|
|
315
|
+
return `${out.join("\n")}\n`;
|
|
316
|
+
}
|
|
317
|
+
/** An item as a person reads it: its text, and its acceptance tests under it. */
|
|
318
|
+
function itemText(line) {
|
|
319
|
+
const item = JSON.parse(line);
|
|
320
|
+
const tests = item[ACCEPTANCE_TESTS] ?? [];
|
|
321
|
+
return [
|
|
322
|
+
item.markdown ?? line,
|
|
323
|
+
...(tests.length > 0 ? [" <acceptance tests>", ...tests.map((test) => ` ${(test.markdown ?? "").replace(/\n/g, "\n ")}`), " </acceptance tests>"] : []),
|
|
324
|
+
].join("\n");
|
|
325
|
+
}
|
|
326
|
+
/** A serialized tree as a person reads it, section by section. */
|
|
327
|
+
export function treeText(serialized) {
|
|
328
|
+
const out = [];
|
|
329
|
+
// Each section in its tag, as every agent is shown a spec (see specPrimer).
|
|
330
|
+
let open;
|
|
331
|
+
for (const line of serialized.split("\n")) {
|
|
332
|
+
if (line === "")
|
|
333
|
+
continue;
|
|
334
|
+
const header = headerOf(line);
|
|
335
|
+
if (header !== undefined) {
|
|
336
|
+
if (open !== undefined)
|
|
337
|
+
out.push(`</${open}>`, "");
|
|
338
|
+
open = sectionTag(header.key);
|
|
339
|
+
out.push(`<${open}>`);
|
|
340
|
+
}
|
|
341
|
+
else
|
|
342
|
+
out.push(itemText(line));
|
|
343
|
+
}
|
|
344
|
+
if (open !== undefined)
|
|
345
|
+
out.push(`</${open}>`);
|
|
346
|
+
return out.join("\n").trim();
|
|
347
|
+
}
|
|
348
|
+
/** What of an item a change touches: its text, its acceptance tests, its linked resources. */
|
|
349
|
+
function changedParts(before, after) {
|
|
350
|
+
if (headerOf(before) !== undefined || headerOf(after) !== undefined)
|
|
351
|
+
return "the text";
|
|
352
|
+
const x = JSON.parse(before);
|
|
353
|
+
const y = JSON.parse(after);
|
|
354
|
+
const names = { markdown: "the text", [ACCEPTANCE_TESTS]: "the acceptance tests", linked_resources: "the linked resources", code_variables: "the code variables" };
|
|
355
|
+
const parts = [...new Set([...Object.keys(x), ...Object.keys(y)])]
|
|
356
|
+
.filter((key) => JSON.stringify(x[key]) !== JSON.stringify(y[key]))
|
|
357
|
+
.map((key) => names[key] ?? key);
|
|
358
|
+
return parts.length === 0 ? "the text" : parts.join(" and ");
|
|
359
|
+
}
|
|
360
|
+
/** A change as the planning agent is shown it. */
|
|
361
|
+
export function describeChange(change) {
|
|
362
|
+
const what = change.kind === "add"
|
|
363
|
+
? `add to ${change.section}, as item ${change.position}`
|
|
364
|
+
: change.kind === "remove"
|
|
365
|
+
? `remove item ${change.position} of ${change.section}`
|
|
366
|
+
: `change ${changedParts(change.before, change.after)} of item ${change.position} of ${change.section}`;
|
|
367
|
+
const header = (line) => (line !== undefined && headerOf(line) !== undefined ? `the section ${headerOf(line).key}` : undefined);
|
|
368
|
+
const body = [
|
|
369
|
+
...(change.before !== undefined ? [" before:", ` ${(header(change.before) ?? itemText(change.before)).replace(/\n/g, "\n ")}`] : []),
|
|
370
|
+
...(change.after !== undefined ? [" after:", ` ${(header(change.after) ?? itemText(change.after)).replace(/\n/g, "\n ")}`] : []),
|
|
371
|
+
];
|
|
372
|
+
return [`${change.id}: ${what}`, ...body].join("\n");
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* What makes a spec not hold together that neither end already has: a concept used before any
|
|
376
|
+
* step defines it, or defined twice - the parser's own concept checks.
|
|
377
|
+
*/
|
|
378
|
+
export function inconsistencies(tree, endpoints) {
|
|
379
|
+
return validateConcepts(tree).filter((error) => !endpoints.has(error));
|
|
380
|
+
}
|
|
381
|
+
const SECTION_HEADING = /^\*\*\*\s*(.+?)\s*\*\*\*\s*$/;
|
|
382
|
+
const CONCEPT = /^- :([^:\s]+):/;
|
|
383
|
+
export function layoutOf(text) {
|
|
384
|
+
const lines = text.split("\n");
|
|
385
|
+
const first = lines.findIndex((line) => SECTION_HEADING.test(line));
|
|
386
|
+
const sections = [];
|
|
387
|
+
const concepts = new Map();
|
|
388
|
+
const tight = new Set();
|
|
389
|
+
let section;
|
|
390
|
+
let previous = "";
|
|
391
|
+
for (const line of first === -1 ? [] : lines.slice(first)) {
|
|
392
|
+
const heading = SECTION_HEADING.exec(line);
|
|
393
|
+
if (heading) {
|
|
394
|
+
section = heading[1];
|
|
395
|
+
sections.push(section);
|
|
396
|
+
previous = "";
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
// An item straight after another's last line, with no blank line: the section is written tight.
|
|
400
|
+
if (section !== undefined && line.startsWith("- ") && previous.trim() !== "" && !SECTION_HEADING.test(previous))
|
|
401
|
+
tight.add(section);
|
|
402
|
+
previous = line;
|
|
403
|
+
const concept = CONCEPT.exec(line);
|
|
404
|
+
if (section === DEFINITIONS && concept && !concepts.has(concept[1]))
|
|
405
|
+
concepts.set(concept[1], concepts.size);
|
|
406
|
+
}
|
|
407
|
+
return { preamble: first === -1 ? "" : lines.slice(0, first).join("\n").trimEnd(), sections, concepts, tight };
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* A serialized tree as the module's `.plain` file would have it: only its own sections, and only
|
|
411
|
+
* the ones with something in them. The functional specs it inherits are the required modules'
|
|
412
|
+
* - `required functional specs` is the parser's key for them, not a section anyone writes. With
|
|
413
|
+
* the file's layout, it is written as the file is: its preamble, its sections in its order, a
|
|
414
|
+
* blank line between items, and the definitions in the file's order rather than the parser's -
|
|
415
|
+
* so a spec diff reads as an edit to the file, at the file's line numbers.
|
|
416
|
+
*/
|
|
417
|
+
export function plainText(serialized, layout) {
|
|
418
|
+
const sections = [];
|
|
419
|
+
for (const line of serialized.split("\n")) {
|
|
420
|
+
if (line === "")
|
|
421
|
+
continue;
|
|
422
|
+
const header = headerOf(line);
|
|
423
|
+
if (header !== undefined)
|
|
424
|
+
sections.push({ key: header.key, items: [] });
|
|
425
|
+
else
|
|
426
|
+
sections.at(-1)?.items.push(itemText(line));
|
|
427
|
+
}
|
|
428
|
+
const own = sections.filter((section) => section.key !== REQUIRED_FUNCTIONAL_SPECS && section.items.length > 0);
|
|
429
|
+
if (layout === undefined)
|
|
430
|
+
return own.map((section) => [`***${section.key}***`, "", ...section.items].join("\n")).join("\n\n");
|
|
431
|
+
const place = (key) => {
|
|
432
|
+
const index = layout.sections.indexOf(key);
|
|
433
|
+
return index === -1 ? layout.sections.length + TREE_SECTIONS.indexOf(key) : index;
|
|
434
|
+
};
|
|
435
|
+
const rank = (item) => {
|
|
436
|
+
const concept = CONCEPT.exec(item);
|
|
437
|
+
return concept ? (layout.concepts.get(concept[1]) ?? Number.MAX_SAFE_INTEGER) : Number.MAX_SAFE_INTEGER;
|
|
438
|
+
};
|
|
439
|
+
const body = [...own]
|
|
440
|
+
.sort((a, b) => place(a.key) - place(b.key))
|
|
441
|
+
.map((section) => {
|
|
442
|
+
// A stable sort: concepts the file does not have yet keep the parser's order, after the rest.
|
|
443
|
+
const items = section.key === DEFINITIONS ? [...section.items].sort((a, b) => rank(a) - rank(b)) : section.items;
|
|
444
|
+
return [`***${section.key}***`, "", items.join(layout.tight.has(section.key) ? "\n" : "\n\n")].join("\n");
|
|
445
|
+
})
|
|
446
|
+
.join("\n\n");
|
|
447
|
+
return layout.preamble === "" ? body : `${layout.preamble}\n\n${body}`;
|
|
448
|
+
}
|
|
449
|
+
/** git's diff of two serialized trees, as ***plain text - see plainText. */
|
|
450
|
+
export function diffSpecText(before, after, file = "spec.plain", layout) {
|
|
451
|
+
return gitDiff(`${plainText(before, layout)}\n`, `${plainText(after, layout)}\n`, 3, [file, file]);
|
|
452
|
+
}
|
|
453
|
+
/** The linked resources of a changed item whose content is different now - each hashed on its own: by their path, with both hashes. */
|
|
454
|
+
export function resourcesChanged(before, after) {
|
|
455
|
+
const resources = (line) => {
|
|
456
|
+
try {
|
|
457
|
+
return JSON.parse(line ?? "").linked_resources ?? [];
|
|
458
|
+
}
|
|
459
|
+
catch {
|
|
460
|
+
return [];
|
|
461
|
+
}
|
|
462
|
+
};
|
|
463
|
+
const was = new Map(resources(before).map((resource) => [resource.target, resource.digest]));
|
|
464
|
+
return resources(after)
|
|
465
|
+
.filter((resource) => was.has(resource.target) && was.get(resource.target) !== resource.digest)
|
|
466
|
+
.map((resource) => ({ target: resource.target, ...(was.get(resource.target) !== undefined ? { was: was.get(resource.target) } : {}), ...(resource.digest !== undefined ? { now: resource.digest } : {}) }));
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* The linked resources a step's changes find changed, as a diff of their own beside the spec's: a file the spec links
|
|
470
|
+
* holds something else now, though the spec's text may read as it did. The trees keep each one's hash, not what it
|
|
471
|
+
* held before - so the hunk says that it changed, and what it holds now is the file itself.
|
|
472
|
+
*/
|
|
473
|
+
export function resourceDiff(changes, file = "spec.plain") {
|
|
474
|
+
// Each resource once, with every item that links it: which spec file, and what in it.
|
|
475
|
+
const byTarget = new Map();
|
|
476
|
+
for (const change of changes.filter((entry) => entry.kind === "change")) {
|
|
477
|
+
for (const resource of resourcesChanged(change.before, change.after)) {
|
|
478
|
+
const entry = byTarget.get(resource.target) ?? { ...(resource.was !== undefined ? { was: resource.was } : {}), ...(resource.now !== undefined ? { now: resource.now } : {}), links: [] };
|
|
479
|
+
const link = linkedBy(change, file);
|
|
480
|
+
if (!entry.links.includes(link))
|
|
481
|
+
entry.links.push(link);
|
|
482
|
+
byTarget.set(resource.target, entry);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
return [...byTarget.entries()]
|
|
486
|
+
.map(([target, { was, now, links }]) => [
|
|
487
|
+
`diff --git a/${target} b/${target}`,
|
|
488
|
+
`--- a/${target}`,
|
|
489
|
+
`+++ b/${target}`,
|
|
490
|
+
"@@ -1 +1 @@",
|
|
491
|
+
`-${target}, linked by ${links.join(" and ")}: its content, sha256 ${was ?? "unknown"}`,
|
|
492
|
+
`+${target}, linked by ${links.join(" and ")}: its content changed, sha256 ${now ?? "unknown"}`,
|
|
493
|
+
"",
|
|
494
|
+
].join("\n"))
|
|
495
|
+
.join("");
|
|
496
|
+
}
|
|
497
|
+
/** The item a change is to, as the person finds it: its concept or its number, in the spec file it is in - a module below's by its name. */
|
|
498
|
+
function linkedBy(change, file) {
|
|
499
|
+
let item = {};
|
|
500
|
+
try {
|
|
501
|
+
item = JSON.parse(change.after ?? change.before ?? "");
|
|
502
|
+
}
|
|
503
|
+
catch {
|
|
504
|
+
/* a header: no item */
|
|
505
|
+
}
|
|
506
|
+
const concept = /^-?\s*(:[^:\s]+:)/.exec(item.markdown?.trim() ?? "")?.[1];
|
|
507
|
+
const what = change.section === DEFINITIONS && concept !== undefined
|
|
508
|
+
? concept
|
|
509
|
+
: change.section === FUNCTIONAL_REQUIREMENTS
|
|
510
|
+
? `functional spec ${change.position}`
|
|
511
|
+
: `${sectionTag(change.section).replace(/s$/, "")} ${change.position}`;
|
|
512
|
+
return `${what} in ${item.module !== undefined ? `${item.module}.plain` : file}`;
|
|
513
|
+
}
|
|
514
|
+
export function makeStep(title, reason, changes, before, after, file, layout) {
|
|
515
|
+
return {
|
|
516
|
+
title,
|
|
517
|
+
reason,
|
|
518
|
+
changes,
|
|
519
|
+
before,
|
|
520
|
+
after,
|
|
521
|
+
tree: parseTree(after),
|
|
522
|
+
diff: diffTrees(before, after),
|
|
523
|
+
// What a linked resource holds changed shows in the step's diff, after the spec's own.
|
|
524
|
+
textDiff: `${diffSpecText(before, after, file, layout)}${resourceDiff(changes, file)}`,
|
|
525
|
+
sections: [...new Set(changes.map((change) => change.section))],
|
|
526
|
+
};
|
|
527
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { DEFINITIONS, getSpecificationItemMarkdown, NON_FUNCTIONAL_REQUIREMENTS, TEST_REQUIREMENTS, } from "../plain/spec.js";
|
|
2
|
+
/** A module's shared sections as they stand, with or without a functional spec to read them through. */
|
|
3
|
+
export function sectionsOf(tree) {
|
|
4
|
+
const render = (heading) => (tree[heading] ?? []).map((item) => getSpecificationItemMarkdown(item, {}, true));
|
|
5
|
+
return {
|
|
6
|
+
[DEFINITIONS]: render(DEFINITIONS),
|
|
7
|
+
[NON_FUNCTIONAL_REQUIREMENTS]: render(NON_FUNCTIONAL_REQUIREMENTS),
|
|
8
|
+
[TEST_REQUIREMENTS]: render(TEST_REQUIREMENTS),
|
|
9
|
+
};
|
|
10
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, extname, join, resolve } from "node:path";
|
|
3
|
+
import { projectDirOf } from "./paths.js";
|
|
4
|
+
/**
|
|
5
|
+
* How the person wants the queue planned - which functional specs go together, what goes first, how big a job is -
|
|
6
|
+
* given to the planner's agents with every plan, as a strong preference.
|
|
7
|
+
*
|
|
8
|
+
* It comes from three places. The conversation sets it (`steer`), for the whole project or one module, and it
|
|
9
|
+
* holds for the session. The project's AGENTS.md can say it once for good: a section whose heading names the queue
|
|
10
|
+
* ("## Queue guidance") is read with every plan, so an edit to it holds from the next. And whatever queues work can
|
|
11
|
+
* pass guidance for that work (a tool's `guidance`), which the queue keeps with it.
|
|
12
|
+
*/
|
|
13
|
+
/** Everything the conversation's steering applies to: every module of the project. */
|
|
14
|
+
export const EVERY_MODULE = "*";
|
|
15
|
+
export function createSteeringStore() {
|
|
16
|
+
const byScope = new Map();
|
|
17
|
+
return {
|
|
18
|
+
get: (scope) => [...(byScope.get(scope) ?? [])],
|
|
19
|
+
set(scope, texts) {
|
|
20
|
+
if (texts.length === 0)
|
|
21
|
+
byScope.delete(scope);
|
|
22
|
+
else
|
|
23
|
+
byScope.set(scope, [...texts]);
|
|
24
|
+
},
|
|
25
|
+
scopes: () => [...byScope.keys()],
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
/** A module's name as the steering names it: its spec file's name, without `.plain`. */
|
|
29
|
+
export function moduleNameOf(plainFile) {
|
|
30
|
+
return basename(plainFile, extname(plainFile));
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* What an AGENTS.md says of the queue: every section whose heading names it - "Queue", "Queue guidance", "How the
|
|
34
|
+
* render queue is planned" - up to the next heading of its level or above. Empty when there is none.
|
|
35
|
+
*/
|
|
36
|
+
export function queueSectionOf(markdown) {
|
|
37
|
+
const lines = markdown.split("\n");
|
|
38
|
+
const out = [];
|
|
39
|
+
let level = 0;
|
|
40
|
+
for (const line of lines) {
|
|
41
|
+
const heading = /^(#{1,6})\s+(.*)$/.exec(line);
|
|
42
|
+
if (heading !== null) {
|
|
43
|
+
const depth = heading[1].length;
|
|
44
|
+
if (level > 0 && depth <= level)
|
|
45
|
+
level = 0;
|
|
46
|
+
if (level === 0 && /\bqueue/i.test(heading[2])) {
|
|
47
|
+
level = depth;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (level > 0)
|
|
52
|
+
out.push(line);
|
|
53
|
+
}
|
|
54
|
+
return out.join("\n").trim();
|
|
55
|
+
}
|
|
56
|
+
/** The AGENTS.md files a spec's steering is read from: where turri runs, and the spec's project - once each. */
|
|
57
|
+
function agentsFiles(plainFile, cwd) {
|
|
58
|
+
const dirs = [resolve(cwd), projectDirOf(resolve(cwd, plainFile))];
|
|
59
|
+
return [...new Set(dirs.map((dir) => join(dir, "AGENTS.md")))].filter((file) => existsSync(file));
|
|
60
|
+
}
|
|
61
|
+
/** The steering a plan of this spec's codebase follows - AGENTS.md's, then the conversation's for every module and for this one. */
|
|
62
|
+
export function steeringFor(plainFile, cwd, store) {
|
|
63
|
+
const fromFiles = agentsFiles(plainFile, cwd).flatMap((file) => {
|
|
64
|
+
try {
|
|
65
|
+
const text = queueSectionOf(readFileSync(file, "utf8"));
|
|
66
|
+
return text === "" ? [] : [{ source: `AGENTS.md (${basename(dirname(file))})`, text }];
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return [];
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
const module = moduleNameOf(plainFile);
|
|
73
|
+
return [
|
|
74
|
+
...fromFiles,
|
|
75
|
+
...store.get(EVERY_MODULE).map((text) => ({ source: "the conversation", text })),
|
|
76
|
+
...store.get(module).map((text) => ({ source: `the conversation, for ${module}`, text })),
|
|
77
|
+
];
|
|
78
|
+
}
|
|
79
|
+
/** The steering, for an agent's task: each with where it came from, and how binding it is. */
|
|
80
|
+
export function steeringBlock(steering) {
|
|
81
|
+
if (steering === undefined || steering.length === 0)
|
|
82
|
+
return [];
|
|
83
|
+
return [
|
|
84
|
+
"<how the queue is to be planned - the person's or the harness's wishes>",
|
|
85
|
+
...steering.map((entry) => `<from source="${entry.source}">\n${entry.text.trim()}\n</from>`),
|
|
86
|
+
"</how the queue is to be planned>",
|
|
87
|
+
"Follow these as a strong preference: they say how the person wants the work cut and ordered. They never override the",
|
|
88
|
+
"limits and checks the plan is held to; where one would, keep to the limit, and say which and why in your answer's notes.",
|
|
89
|
+
"",
|
|
90
|
+
];
|
|
91
|
+
}
|