@pmelab/gtd 64.0.0 → 64.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/.claude-plugin/plugin.json +1 -1
- package/README.md +8 -8
- package/dist/chunks/{steering-DkA51qDa.mjs → steering-Cem5i6Xl.mjs} +421 -1
- package/dist/chunks/{testkit-zGjNeQNL.mjs → testkit-DfWZQCWQ.mjs} +63 -16
- package/dist/gtd.bundle.mjs +485 -19
- package/dist/gtd.core.mjs +117 -110
- package/dist/steering.mjs +2 -2
- package/dist/testkit/gherkin.mjs +1 -1
- package/dist/testkit.mjs +1 -1
- package/dist/web/main.css +1 -1
- package/dist/workflow-kit.mjs +1 -1
- package/package.json +1 -1
- package/src/cli/Cli.ts +13 -1
- package/src/cli/Program.ts +23 -4
- package/src/edge/Edge.ts +23 -9
- package/src/emit/SteeringMode.ts +21 -1
- package/src/flows/index.ts +1 -0
- package/src/flows/runtime.test.ts +2 -1
- package/src/flows/runtime.ts +8 -2
- package/src/steering/Declared.test.ts +218 -0
- package/src/steering/Declared.ts +607 -0
- package/src/steering/FORMATS.md +3 -0
- package/src/steering/index.ts +12 -0
- package/src/steering/internal.ts +1 -0
- package/src/twins/EmittedScriptRecognizer.ts +5 -3
- package/src/twins/EmittedScripts.test.ts +19 -0
- package/src/workflow/SteeringFormats.ts +4 -0
- package/src/workflow/Workflow.ts +2 -0
- package/src/workflow/index.ts +5 -1
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { formatFinding, listFormat, sectionFormat } from "./index.js"
|
|
3
|
+
import { formatFromSpec, steeringFormatFor } from "./internal.js"
|
|
4
|
+
|
|
5
|
+
const criteria = listFormat({
|
|
6
|
+
name: "criteria",
|
|
7
|
+
section: { title: /^acceptance criteria$/i, example: "Acceptance criteria", level: 2 },
|
|
8
|
+
item: { id: /^AC-\d+$/, separator: ":", example: "AC-1" },
|
|
9
|
+
ids: { unique: true },
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
const doc = (...lines: string[]): string => `${lines.join("\n")}\n`
|
|
13
|
+
|
|
14
|
+
const problems = (format: { validate: (c: string) => readonly unknown[] }, content: string) =>
|
|
15
|
+
format.validate(content).map((f) => formatFinding("F.md", f as never))
|
|
16
|
+
|
|
17
|
+
describe("listFormat", () => {
|
|
18
|
+
it("parses ID'd items, joining indented continuation lines", () => {
|
|
19
|
+
const parsed = criteria.parse(
|
|
20
|
+
doc(
|
|
21
|
+
"# Plan",
|
|
22
|
+
"",
|
|
23
|
+
"## Acceptance criteria",
|
|
24
|
+
"",
|
|
25
|
+
"- AC-1: first",
|
|
26
|
+
" wrapped",
|
|
27
|
+
"- AC-2: second",
|
|
28
|
+
"",
|
|
29
|
+
"## Next",
|
|
30
|
+
"",
|
|
31
|
+
"- not read",
|
|
32
|
+
),
|
|
33
|
+
)
|
|
34
|
+
expect(parsed.problems).toEqual([])
|
|
35
|
+
expect(parsed.items).toEqual([
|
|
36
|
+
{ line: 4, id: "AC-1", text: "first wrapped" },
|
|
37
|
+
{ line: 6, id: "AC-2", text: "second" },
|
|
38
|
+
])
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it("validates its own sample, and describes itself", () => {
|
|
42
|
+
expect(criteria.validate(criteria.sample)).toEqual([])
|
|
43
|
+
expect(criteria.sample).toBe("## Acceptance criteria\n\n- AC-1: Describe the item.\n")
|
|
44
|
+
expect(criteria.instructions).toContain("`## Acceptance criteria`")
|
|
45
|
+
expect(criteria.instructions).toContain("`AC-1`")
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it("reports a missing, repeated or misleveled section", () => {
|
|
49
|
+
expect(problems(criteria, doc("# Plan"))).toEqual([
|
|
50
|
+
"missing the `## Acceptance criteria` section",
|
|
51
|
+
])
|
|
52
|
+
expect(problems(criteria, doc("### Acceptance criteria", "", "- AC-1: a"))).toEqual([
|
|
53
|
+
'F.md:1: "Acceptance criteria" is a level-3 heading — write it as `## Acceptance criteria`',
|
|
54
|
+
])
|
|
55
|
+
expect(
|
|
56
|
+
problems(
|
|
57
|
+
criteria,
|
|
58
|
+
doc("## Acceptance criteria", "", "- AC-1: a", "", "## Acceptance criteria"),
|
|
59
|
+
),
|
|
60
|
+
).toEqual(['F.md:5: "Acceptance criteria" repeats the section on line 1 — keep one'])
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it("reports markers it does not allow, and mixed markers", () => {
|
|
64
|
+
const numbered = listFormat({ name: "steps", markers: ["-", "1."] })
|
|
65
|
+
expect(problems(criteria, doc("## Acceptance criteria", "", "* AC-1: a"))).toEqual([
|
|
66
|
+
"F.md:3: `*` is not an allowed marker — use `-`",
|
|
67
|
+
])
|
|
68
|
+
expect(problems(numbered, doc("- a", "1. b"))).toEqual([
|
|
69
|
+
"F.md:2: `1.` mixes markers in one list — the list started with `-`",
|
|
70
|
+
])
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it("reads a numbered list it allows rather than parsing it as empty", () => {
|
|
74
|
+
const numbered = listFormat({ name: "signals", markers: ["1."] })
|
|
75
|
+
expect(numbered.parse(doc("1. a", "2. b")).items.map((i) => i.text)).toEqual(["a", "b"])
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it("reports an unindented continuation line", () => {
|
|
79
|
+
expect(problems(criteria, doc("## Acceptance criteria", "", "- AC-1: a", "b"))).toEqual([
|
|
80
|
+
"F.md:4: continuation line is not indented — indent it under its item, or separate it with a blank line",
|
|
81
|
+
])
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it("never reads a fenced item, and reports an unclosed fence", () => {
|
|
85
|
+
const parsed = criteria.parse(
|
|
86
|
+
doc("## Acceptance criteria", "", "- AC-1: a", "", "```", "- AC-2: b", "```"),
|
|
87
|
+
)
|
|
88
|
+
expect(parsed.items.map((i) => i.id)).toEqual(["AC-1"])
|
|
89
|
+
expect(parsed.problems.map((p) => p.message)).toContain(
|
|
90
|
+
"a list item inside a code fence is not read as an item",
|
|
91
|
+
)
|
|
92
|
+
expect(
|
|
93
|
+
problems(criteria, doc("## Acceptance criteria", "", "- AC-1: a", "", "```", "x")),
|
|
94
|
+
).toEqual(["F.md:5: code fence is never closed"])
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it("keeps a fence indented under its item as part of the item", () => {
|
|
98
|
+
expect(
|
|
99
|
+
problems(
|
|
100
|
+
criteria,
|
|
101
|
+
doc("## Acceptance criteria", "", "- AC-1: a", "", " ```", " - x", " ```", "- AC-2: b"),
|
|
102
|
+
),
|
|
103
|
+
).toEqual([])
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it("reports duplicate and missing IDs, and empty items", () => {
|
|
107
|
+
expect(
|
|
108
|
+
problems(
|
|
109
|
+
criteria,
|
|
110
|
+
doc("## Acceptance criteria", "", "- AC-1: a", "- AC-1: b", "- c", "- AC-3:"),
|
|
111
|
+
),
|
|
112
|
+
).toEqual([
|
|
113
|
+
"F.md:4: duplicate ID AC-1 — first on line 3",
|
|
114
|
+
"F.md:5: item has no ID — start it `AC-1: …`",
|
|
115
|
+
"F.md:6: empty item — AC-3 has no text",
|
|
116
|
+
])
|
|
117
|
+
expect(problems(listFormat({ name: "plain" }), doc("- a", "-"))).toEqual(["F.md:2: empty item"])
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it("reports text between items and nesting", () => {
|
|
121
|
+
expect(
|
|
122
|
+
problems(
|
|
123
|
+
criteria,
|
|
124
|
+
doc(
|
|
125
|
+
"## Acceptance criteria",
|
|
126
|
+
"",
|
|
127
|
+
"- AC-1: a",
|
|
128
|
+
"",
|
|
129
|
+
"Aside.",
|
|
130
|
+
"",
|
|
131
|
+
"- AC-2: b",
|
|
132
|
+
" - AC-3: c",
|
|
133
|
+
),
|
|
134
|
+
),
|
|
135
|
+
).toEqual([
|
|
136
|
+
"F.md:5: text between items — make it part of an item, or move it out of the list",
|
|
137
|
+
"F.md:8: nested list — this format is flat, one level of items",
|
|
138
|
+
])
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it("forbids, strips or requires checkboxes", () => {
|
|
142
|
+
expect(problems(criteria, doc("## Acceptance criteria", "", "- [ ] AC-1: a"))).toEqual([
|
|
143
|
+
"F.md:3: no checkboxes in this list — drop the `[ ]`",
|
|
144
|
+
])
|
|
145
|
+
const todo = listFormat({ name: "todo", checkbox: "require" })
|
|
146
|
+
expect(todo.parse(doc("- [x] a")).items).toEqual([{ line: 0, text: "a", checked: true }])
|
|
147
|
+
expect(problems(todo, doc("- a"))).toEqual([
|
|
148
|
+
"F.md:1: every item starts with a checkbox — write `- [ ] …`",
|
|
149
|
+
])
|
|
150
|
+
const stripped = listFormat({ name: "stripped", checkbox: "strip" })
|
|
151
|
+
expect(stripped.parse(doc("- [ ] a", "- b")).items.map((i) => i.checked)).toEqual([
|
|
152
|
+
false,
|
|
153
|
+
false,
|
|
154
|
+
])
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it("forbids wrapping when told to", () => {
|
|
158
|
+
const flat = listFormat({ name: "flat", wrap: "forbid" })
|
|
159
|
+
expect(problems(flat, doc("- a", " b"))).toEqual([
|
|
160
|
+
"F.md:2: one line per item — this line continues the one above",
|
|
161
|
+
])
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it("refuses an inconsistent declaration", () => {
|
|
165
|
+
expect(() => listFormat({ name: "Bad Name" })).toThrow("format name")
|
|
166
|
+
expect(() =>
|
|
167
|
+
listFormat({ name: "x", item: { id: /^A\d$/, separator: ":", example: "B1" } }),
|
|
168
|
+
).toThrow("does not match")
|
|
169
|
+
expect(() => listFormat({ name: "x", section: { title: /a/, level: 2 } })).toThrow("example")
|
|
170
|
+
})
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
describe("sectionFormat", () => {
|
|
174
|
+
const findings = sectionFormat({ name: "notes", level: 2, title: "unique" })
|
|
175
|
+
|
|
176
|
+
it("parses sections with their bodies", () => {
|
|
177
|
+
expect(
|
|
178
|
+
findings.parse(doc("# T", "", "## A", "", "one", "### deeper", "## B")).sections,
|
|
179
|
+
).toEqual([
|
|
180
|
+
{ line: 2, title: "A", body: "one\n### deeper" },
|
|
181
|
+
{ line: 6, title: "B", body: "" },
|
|
182
|
+
])
|
|
183
|
+
expect(findings.validate(findings.sample)).toEqual([])
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it("reports repeated titles, a shallower heading, and a heading in an unclosed fence", () => {
|
|
187
|
+
expect(problems(findings, doc("## A", "## a", "# Up", "```", "## C"))).toEqual([
|
|
188
|
+
'F.md:2: "a" repeats the section on line 1 — titles are unique',
|
|
189
|
+
'F.md:3: "Up" is a level-1 heading — sections are `##`',
|
|
190
|
+
"F.md:4: code fence is never closed",
|
|
191
|
+
])
|
|
192
|
+
})
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
describe("formatFromSpec", () => {
|
|
196
|
+
it("rebuilds the same format from its spec, RegExps included", () => {
|
|
197
|
+
const rebuilt = formatFromSpec(criteria.spec)
|
|
198
|
+
const content = doc("## Acceptance criteria", "", "- AC-1: a", "- AC-1: b", "* x")
|
|
199
|
+
expect(rebuilt.name).toBe("criteria")
|
|
200
|
+
expect(rebuilt.validate(content)).toEqual(criteria.validate(content))
|
|
201
|
+
const notes = sectionFormat({ name: "notes", level: 2, title: "unique" })
|
|
202
|
+
expect(formatFromSpec(notes.spec).validate("## A\n## A\n")).toEqual(
|
|
203
|
+
notes.validate("## A\n## A\n"),
|
|
204
|
+
)
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it("throws on a spec no declaration produces", () => {
|
|
208
|
+
expect(() => formatFromSpec('{"kind":"nope"}')).toThrow("not a declared format spec")
|
|
209
|
+
})
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
describe("the built-in formats", () => {
|
|
213
|
+
it("are not declared formats — none of these rules apply to them", () => {
|
|
214
|
+
expect(
|
|
215
|
+
steeringFormatFor("findings")!.validate("## A\n\n- [ ] fix\n\n## A\n\n- [ ] fix\n"),
|
|
216
|
+
).toEqual([])
|
|
217
|
+
})
|
|
218
|
+
})
|