@pmelab/gtd 63.0.0 → 64.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +12 -11
  3. package/dist/chunks/{flows-DENt0uWV.mjs → flows-B7ZRCe-B.mjs} +1 -1
  4. package/dist/chunks/{rolldown-runtime-8H4AJuhK.mjs → rolldown-runtime-D7D4PA-g.mjs} +0 -1
  5. package/dist/chunks/{steering-DF_BMadH.mjs → steering-Cem5i6Xl.mjs} +422 -2
  6. package/dist/chunks/{testkit-DwypA7cH.mjs → testkit-DfWZQCWQ.mjs} +1073 -328
  7. package/dist/flows.mjs +1 -1
  8. package/dist/gtd.bundle.mjs +4372 -1310
  9. package/dist/gtd.core.mjs +126 -118
  10. package/dist/steering.mjs +2 -2
  11. package/dist/testkit/gherkin.mjs +2 -2
  12. package/dist/testkit.mjs +2 -2
  13. package/dist/workflow-kit.mjs +2 -2
  14. package/package.json +1 -1
  15. package/src/cli/Cli.test.ts +5 -8
  16. package/src/cli/Cli.ts +22 -28
  17. package/src/cli/Output.ts +2 -1
  18. package/src/cli/Program.test.ts +55 -53
  19. package/src/cli/Program.ts +34 -34
  20. package/src/cli/ToolCommands.ts +3 -4
  21. package/src/edge/Edge.test.ts +38 -12
  22. package/src/edge/Edge.ts +53 -19
  23. package/src/emit/SteeringMode.test.ts +4 -2
  24. package/src/emit/SteeringMode.ts +31 -10
  25. package/src/flows/index.ts +1 -0
  26. package/src/flows/runtime.test.ts +2 -1
  27. package/src/flows/runtime.ts +8 -2
  28. package/src/gherkin/messages.test.ts +1 -1
  29. package/src/gherkin/module-config.feature +1 -1
  30. package/src/gherkin/testkit.feature +2 -2
  31. package/src/install/Version.ts +14 -13
  32. package/src/platform/Host.ts +75 -22
  33. package/src/platform/NodePinFile.ts +44 -0
  34. package/src/platform/NodeRuntime.ts +19 -0
  35. package/src/platform/NodeSync.ts +64 -0
  36. package/src/platform/PinFile.ts +0 -50
  37. package/src/platform/Sha.ts +121 -0
  38. package/src/platform/SyncIo.ts +35 -0
  39. package/src/platform/Workspace.test.ts +8 -3
  40. package/src/platform/Workspace.ts +82 -123
  41. package/src/platform/WorktreeState.test.ts +8 -4
  42. package/src/platform/WorktreeState.ts +21 -19
  43. package/src/platform/index.ts +18 -5
  44. package/src/replay/Format.test.ts +16 -13
  45. package/src/replay/Format.ts +4 -2
  46. package/src/replay/Replay.test.ts +25 -5
  47. package/src/replay/Replay.ts +10 -6
  48. package/src/replay/Trailers.test.ts +10 -10
  49. package/src/replay/Trailers.ts +23 -1
  50. package/src/replay/index.ts +1 -0
  51. package/src/steering/Declared.test.ts +218 -0
  52. package/src/steering/Declared.ts +607 -0
  53. package/src/steering/FORMATS.md +3 -0
  54. package/src/steering/index.ts +12 -0
  55. package/src/steering/internal.ts +1 -0
  56. package/src/step/planStart.test.ts +2 -2
  57. package/src/step/planStep.test.ts +4 -4
  58. package/src/testkit/TestRepo.test.ts +1 -1
  59. package/src/testkit/TestRepo.ts +4 -18
  60. package/src/twins/EmittedScriptRecognizer.ts +5 -3
  61. package/src/twins/EmittedScripts.test.ts +19 -0
  62. package/src/twins/Host.test.ts +151 -0
  63. package/src/twins/Layers.test.ts +2 -2
  64. package/src/twins/Layers.ts +44 -18
  65. package/src/twins/Path.test.ts +52 -0
  66. package/src/twins/SyncIo.test.ts +153 -0
  67. package/src/twins/SyncIo.ts +159 -0
  68. package/src/twins/cliIo.ts +8 -1
  69. package/src/twins/index.ts +5 -0
  70. package/src/workflow/SteeringFormats.ts +4 -0
  71. package/src/workflow/Workflow.ts +2 -0
  72. package/src/workflow/discovery.test.ts +6 -5
  73. package/src/workflow/discovery.ts +43 -32
  74. package/src/workflow/fingerprint.test.ts +23 -3
  75. package/src/workflow/fingerprint.ts +9 -11
  76. package/src/workflow/index.ts +5 -1
  77. package/src/workflow/load.test.ts +16 -6
  78. package/src/workflow/load.ts +107 -69
  79. package/src/workflow/pin.test.ts +3 -2
  80. package/src/workflow/pin.ts +17 -11
@@ -1226,7 +1226,7 @@ describe("pin commits", () => {
1226
1226
  const pinned = () =>
1227
1227
  new History()
1228
1228
  .land("human", "idle", 1, "build", { "NOTE.md": "n" })
1229
- .raw("gtd(check): pin → build\n\nGtd-Pin: add", { [PIN]: "gtdpin 1 gtd 1\n" })
1229
+ .raw("gtd(check): gtd:pin → build\n\nGtd-Pin: add", { [PIN]: "gtdpin 1 gtd 1\n" })
1230
1230
 
1231
1231
  it("are never a turn: the next step answers from the commit after the pin", async () => {
1232
1232
  const flow = async () => {
@@ -1268,15 +1268,15 @@ describe("pin commits", () => {
1268
1268
  await agent("build", "b")
1269
1269
  }
1270
1270
  const h = pinned()
1271
- .land("agent", "build", 1, "unpin", { "a.ts": "a" })
1272
- .raw("gtd(check): unpin → idle\n\nGtd-Pin: remove", { [PIN]: null })
1271
+ .land("agent", "build", 1, "gtd:unpin", { "a.ts": "a" })
1272
+ .raw("gtd(check): gtd:unpin → idle\n\nGtd-Pin: remove", { [PIN]: null })
1273
1273
  expect((await replayOf(flow, h)).kind).toBe("ended")
1274
1274
  })
1275
1275
  })
1276
1276
 
1277
1277
  describe("replay: a repin's anchor", () => {
1278
1278
  const anchorTo = (to: string, at: number): string =>
1279
- formatCommitMessage({ actor: "check", from: "pin", to, pin: "add", anchor: hashOf(at) })
1279
+ formatCommitMessage({ actor: "check", from: "gtd:pin", to, pin: "add", anchor: hashOf(at) })
1280
1280
 
1281
1281
  // The old flow went idle → build → old; this one goes idle → build → check.
1282
1282
  const anchoredHistory = (): History =>
@@ -1345,11 +1345,31 @@ describe("replay: a repin's anchor", () => {
1345
1345
  const h = new History()
1346
1346
  .land("human", "idle", 1, "build", { "a.txt": "a" })
1347
1347
  .land("agent", "build", 1, "old")
1348
- .raw(anchorTo("unpin", 1))
1348
+ .raw(anchorTo("gtd:unpin", 1))
1349
1349
  const outcome = await replayOf(async () => void (await human("idle")), h)
1350
1350
  expect(outcome.kind).toBe("ended")
1351
1351
  })
1352
1352
 
1353
+ it("closes the process when a format 3 anchor names the unpin by its old name", async () => {
1354
+ const h = new History()
1355
+ .land("human", "idle", 1, "build", { "a.txt": "a" })
1356
+ .land("agent", "build", 1, "old")
1357
+ .raw(`gtd(check): pin → unpin\n\nGtd-Pin: add\nGtd-Anchor: ${hashOf(1)}\nGtd-Format: 3`)
1358
+ const outcome = await replayOf(async () => void (await human("idle")), h)
1359
+ expect(outcome.kind).toBe("ended")
1360
+ })
1361
+
1362
+ it("reads a format 4 anchor naming unpin as a step", async () => {
1363
+ const outcome = await replayOf(
1364
+ async () => void (await human("idle")),
1365
+ new History()
1366
+ .land("human", "idle", 1, "build", { "a.txt": "a" })
1367
+ .land("agent", "build", 1, "old")
1368
+ .raw(anchorTo("unpin", 1)),
1369
+ )
1370
+ expect(outcome).toMatchObject({ kind: "divergence", reached: undefined })
1371
+ })
1372
+
1353
1373
  it("fails when it names no commit replay reads", async () => {
1354
1374
  const h = anchoredHistory().raw(anchorTo("build", 2))
1355
1375
  const outcome = await replayOf(async () => void (await human("idle")), h)
@@ -29,10 +29,14 @@ import {
29
29
  type CommitMessage,
30
30
  type JudgeVerdict,
31
31
  type StepId,
32
- PIN_STATES,
32
+ ENGINE_PREFIX,
33
+ pinStateIn,
33
34
  } from "./Trailers.js"
34
35
 
35
- const UNPIN_STATE = PIN_STATES.remove
36
+ const namesUnpin = (commit: ParsedCommit): boolean => {
37
+ const unpin = pinStateIn(commit.parsed.format, "remove")
38
+ return unpin !== undefined && commit.parsed.parsed?.to === unpin
39
+ }
36
40
 
37
41
  export interface EpisodeCommit {
38
42
  readonly hash: string
@@ -402,7 +406,7 @@ export const replay = async (raw: ReplayInput): Promise<ReplayOutcome> => {
402
406
  position = commit
403
407
  const to = commit.parsed.parsed?.to
404
408
  expectedNext =
405
- to === undefined || to === UNPIN_STATE ? undefined : { name: to, hash: commit.hash }
409
+ to === undefined || namesUnpin(commit) ? undefined : { name: to, hash: commit.hash }
406
410
  anchorPending = expectedNext !== undefined
407
411
  }
408
412
  }
@@ -672,7 +676,7 @@ export const replay = async (raw: ReplayInput): Promise<ReplayOutcome> => {
672
676
  // A turn that ends a pinned process names the unpin, which is no step.
673
677
  const expectNextOf = (commit: ParsedCommit): void => {
674
678
  const to = commit.parsed.parsed?.to
675
- if (to !== undefined && to !== UNPIN_STATE) expectedNext = { name: to, hash: commit.hash }
679
+ if (to !== undefined && !namesUnpin(commit)) expectedNext = { name: to, hash: commit.hash }
676
680
  }
677
681
 
678
682
  const noteKept = (step: ReachedStep, commit: ParsedCommit): void => {
@@ -719,8 +723,8 @@ export const replay = async (raw: ReplayInput): Promise<ReplayOutcome> => {
719
723
 
720
724
  // gtd.config.ts is loaded without a type check.
721
725
  const requestFault = (request: Exclude<StepRequest, { kind: "restart" }>): string | undefined => {
722
- if (Object.values(PIN_STATES).includes(scoped(request.name))) {
723
- return `gtd: step "${scoped(request.name)}": the name is reserved for the pin and unpin beats — rename the step`
726
+ if (scoped(request.name).startsWith(ENGINE_PREFIX)) {
727
+ return `gtd: step "${scoped(request.name)}": the "${ENGINE_PREFIX}" prefix is reserved for gtd's own beats — rename the step`
724
728
  }
725
729
  if (request.kind === "run" && typeof request.body !== "string") {
726
730
  return `gtd: step "${scoped(request.name)}": a run() body is a shell script string — decide in flow code, then render the script (see checkScript and friends)`
@@ -46,7 +46,7 @@ describe("the commit-message codec", () => {
46
46
  reviewBase: "abc",
47
47
  })
48
48
  expect(message).toBe(
49
- "gtd(human): fix-precheck\n\nGtd-Workflow: fix\nGtd-Review-Base: abc\nGtd-Format: 3",
49
+ "gtd(human): fix-precheck\n\nGtd-Workflow: fix\nGtd-Review-Base: abc\nGtd-Format: 4",
50
50
  )
51
51
  expect(parseCommitMessage(message).workflow).toBe("fix")
52
52
  expect(parseCommitMessage("gtd(human): fix-precheck").workflow).toBeUndefined()
@@ -56,7 +56,7 @@ describe("the commit-message codec", () => {
56
56
  const one = formatCommitMessage({ actor: "human", to: "x", vars: { b: "2", a: "1", c: "" } })
57
57
  const two = formatCommitMessage({ actor: "human", to: "x", vars: { c: "", a: "1", b: "2" } })
58
58
  expect(one).toBe(two)
59
- expect(one).toBe("gtd(human): x\n\nGtd-Var: a=1\nGtd-Var: b=2\nGtd-Var: c=\nGtd-Format: 3")
59
+ expect(one).toBe("gtd(human): x\n\nGtd-Var: a=1\nGtd-Var: b=2\nGtd-Var: c=\nGtd-Format: 4")
60
60
  expect(parseCommitMessage(one).vars).toEqual({ a: "1", b: "2", c: "" })
61
61
  })
62
62
 
@@ -81,8 +81,8 @@ describe("the commit-message codec", () => {
81
81
  it("reads a Gtd-Version carrying control bytes as unknown, and keeps spaces and parens", () => {
82
82
  const withVersion = (value: string) =>
83
83
  formatCommitMessage({ actor: "a", to: "b" }).replace(
84
- "Gtd-Format: 3",
85
- `Gtd-Version: ${value}\nGtd-Format: 3`,
84
+ "Gtd-Format: 4",
85
+ `Gtd-Version: ${value}\nGtd-Format: 4`,
86
86
  )
87
87
  expect(parseCommitMessage(withVersion("1.0\u001b]0;x\u0007")).version).toBeUndefined()
88
88
  expect(parseCommitMessage(withVersion("1.0\u009b")).version).toBeUndefined()
@@ -100,18 +100,18 @@ describe("the commit-message codec", () => {
100
100
 
101
101
  it("writes the current Gtd-Format when branch and version are written", () => {
102
102
  const message = formatCommitMessage({ actor: "a", to: "b", branch: "m", version: "1" })
103
- expect(message.endsWith("Gtd-Format: 3")).toBe(true)
103
+ expect(message.endsWith("Gtd-Format: 4")).toBe(true)
104
104
  })
105
105
 
106
106
  it("writes Gtd-Format last on every message, even one with no other trailer", () => {
107
107
  const message = formatCommitMessage({ actor: "agent", to: "building" })
108
- expect(message).toBe("gtd(agent): building\n\nGtd-Format: 3")
109
- expect(parseCommitMessage(message).format).toBe(3)
108
+ expect(message).toBe("gtd(agent): building\n\nGtd-Format: 4")
109
+ expect(parseCommitMessage(message).format).toBe(4)
110
110
  })
111
111
 
112
112
  it("reads a message without Gtd-Format as format 1, and an explicit one as written", () => {
113
113
  expect(parseCommitMessage("gtd(agent): a → b\n\nGtd-Step: a#1").format).toBe(1)
114
- expect(parseCommitMessage("gtd(agent): a → b\n\nGtd-Format: 3").format).toBe(3)
114
+ expect(parseCommitMessage("gtd(agent): a → b\n\nGtd-Format: 4").format).toBe(4)
115
115
  expect(parseCommitMessage("gtd(agent): a → b\n\nGtd-Format: two").format).toBe(1)
116
116
  })
117
117
 
@@ -149,7 +149,7 @@ describe("Gtd-Kept", () => {
149
149
  it("reads undefined when absent, and leaves Gtd-Format at the current format", () => {
150
150
  const parsed = parseCommitMessage(formatCommitMessage({ actor: "agent", to: "b" }))
151
151
  expect(parsed.kept).toBeUndefined()
152
- expect(parsed.format).toBe(3)
152
+ expect(parsed.format).toBe(4)
153
153
  })
154
154
  })
155
155
 
@@ -289,7 +289,7 @@ describe("Gtd-Mark", () => {
289
289
  const at = lines.findIndex((l) => l.startsWith("Gtd-Mark:"))
290
290
  expect(lines[at - 1]).toMatch(/^Gtd-Judge:/)
291
291
  expect(lines[at + 1]).toMatch(/^Gtd-Approved:/)
292
- expect(lines).toContain("Gtd-Format: 3")
292
+ expect(lines).toContain("Gtd-Format: 4")
293
293
  })
294
294
 
295
295
  it("writes nothing without marks", () => {
@@ -70,8 +70,30 @@ export interface Approval {
70
70
 
71
71
  export type PinChange = PinMark
72
72
 
73
+ /** Step names a workflow may not take: gtd's own beats live under it. */
74
+ export const ENGINE_PREFIX = "gtd:"
75
+
73
76
  /** What a pin and an unpin beat rest at, and their commits name — neither is a step of the workflow. */
74
- export const PIN_STATES: Readonly<Record<PinChange, string>> = { add: "pin", remove: "unpin" }
77
+ export const PIN_STATES: Readonly<Record<PinChange, string>> = {
78
+ add: `${ENGINE_PREFIX}pin`,
79
+ remove: `${ENGINE_PREFIX}unpin`,
80
+ }
81
+
82
+ /** The names formats 2 and 3 gave the pin beats, before `gtd:` reserved them. */
83
+ export const LEGACY_PIN_STATES: Readonly<Record<PinChange, string>> = {
84
+ add: "pin",
85
+ remove: "unpin",
86
+ }
87
+
88
+ const SCOPED_PIN_FORMAT = 4
89
+
90
+ /** The name a commit in `format` gives a pin beat; format 1 has none. */
91
+ export const pinStateIn = (format: number, change: PinChange): string | undefined =>
92
+ format >= SCOPED_PIN_FORMAT
93
+ ? PIN_STATES[change]
94
+ : format >= 2
95
+ ? LEGACY_PIN_STATES[change]
96
+ : undefined
75
97
 
76
98
  export interface CommitMessage {
77
99
  readonly subject: string
@@ -17,6 +17,7 @@ export {
17
17
  type CommitSpec,
18
18
  type JudgeVerdict,
19
19
  type PinChange,
20
+ LEGACY_PIN_STATES,
20
21
  PIN_STATES,
21
22
  } from "./Trailers.js"
22
23
  export { accessShapeFault } from "./Access.js"
@@ -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
+ })