@pmelab/gtd 64.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.
package/dist/steering.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { A as parseThreads, C as FREE_TEXT_PLACEHOLDER, D as findingEntries, E as NOISE_HEADING, N as formatFinding, O as openThreadFindings, S as unansweredNotes, T as FIX_BOX, _ as reviewChunks, a as sections, b as reviewRisks, c as openThreads, d as freeFormFormat, f as applySteeringEdits, g as REVIEW_SECTIONS, h as ANSWER_WONT_FIX, i as sectionBodies, j as parseThreadsWithFindings, k as parseFootnotes, l as steeringFormatFor, m as ANSWER_LEAD, n as stripCodeThreads, o as threads, p as ANSWER_FIX, r as openQuestions, t as parseCodeThreads, v as reviewDecisions, w as isAnswered, x as reviewSections, y as reviewNotes } from "./chunks/steering-DkA51qDa.mjs";
2
- export { ANSWER_FIX, ANSWER_LEAD, ANSWER_WONT_FIX, FIX_BOX, FREE_TEXT_PLACEHOLDER, NOISE_HEADING, REVIEW_SECTIONS, applySteeringEdits, findingEntries, formatFinding, freeFormFormat, isAnswered, openQuestions, openThreadFindings, openThreads, parseCodeThreads, parseFootnotes, parseThreads, parseThreadsWithFindings, reviewChunks, reviewDecisions, reviewNotes, reviewRisks, reviewSections, sectionBodies, sections, steeringFormatFor, stripCodeThreads, threads, unansweredNotes };
1
+ import { A as findingEntries, C as reviewRisks, D as isAnswered, E as FREE_TEXT_PLACEHOLDER, I as formatFinding, M as parseFootnotes, N as parseThreads, O as FIX_BOX, P as parseThreadsWithFindings, S as reviewNotes, T as unansweredNotes, _ as ANSWER_LEAD, a as sections, b as reviewChunks, c as openThreads, f as listFormat, g as ANSWER_FIX, h as applySteeringEdits, i as sectionBodies, j as openThreadFindings, k as NOISE_HEADING, l as steeringFormatFor, m as freeFormFormat, n as stripCodeThreads, o as threads, p as sectionFormat, r as openQuestions, t as parseCodeThreads, v as ANSWER_WONT_FIX, w as reviewSections, x as reviewDecisions, y as REVIEW_SECTIONS } from "./chunks/steering-Cem5i6Xl.mjs";
2
+ export { ANSWER_FIX, ANSWER_LEAD, ANSWER_WONT_FIX, FIX_BOX, FREE_TEXT_PLACEHOLDER, NOISE_HEADING, REVIEW_SECTIONS, applySteeringEdits, findingEntries, formatFinding, freeFormFormat, isAnswered, listFormat, openQuestions, openThreadFindings, openThreads, parseCodeThreads, parseFootnotes, parseThreads, parseThreadsWithFindings, reviewChunks, reviewDecisions, reviewNotes, reviewRisks, reviewSections, sectionBodies, sectionFormat, sections, steeringFormatFor, stripCodeThreads, threads, unansweredNotes };
@@ -1,4 +1,4 @@
1
- import { n as runsItsScript, r as testRepo } from "../chunks/testkit-zGjNeQNL.mjs";
1
+ import { n as runsItsScript, r as testRepo } from "../chunks/testkit-DfWZQCWQ.mjs";
2
2
  import { pathToFileURL } from "node:url";
3
3
  import { resolve } from "node:path";
4
4
  import { After, AfterAll, Given, QuickPickleWorld, Then, When, setWorldConstructor } from "quickpickle";
package/dist/testkit.mjs CHANGED
@@ -1,3 +1,3 @@
1
1
  import { H as installContext } from "./chunks/flows-B7ZRCe-B.mjs";
2
- import { a as verdictWhen, i as verdict, n as runsItsScript, o as whenPrompted, r as testRepo, t as inTurn } from "./chunks/testkit-zGjNeQNL.mjs";
2
+ import { a as verdictWhen, i as verdict, n as runsItsScript, o as whenPrompted, r as testRepo, t as inTurn } from "./chunks/testkit-DfWZQCWQ.mjs";
3
3
  export { inTurn, installContext, runsItsScript, testRepo, verdict, verdictWhen, whenPrompted };
@@ -1,5 +1,5 @@
1
1
  import { B as head, F as changesSince, G as mark, P as changes, X as read, Z as refuse, d as hasThreadFor, et as scope, g as requireReplies, v as requireThreadsClosed } from "./chunks/flows-B7ZRCe-B.mjs";
2
- import { A as parseThreads, S as unansweredNotes, b as reviewRisks, n as stripCodeThreads, v as reviewDecisions, y as reviewNotes } from "./chunks/steering-DkA51qDa.mjs";
2
+ import { C as reviewRisks, N as parseThreads, S as reviewNotes, T as unansweredNotes, n as stripCodeThreads, x as reviewDecisions } from "./chunks/steering-Cem5i6Xl.mjs";
3
3
  //#region src/workflow-kit/health.ts
4
4
  /** Hand a red suite that repeated attempts could not fix to a person, until `limit` rounds are spent. */
5
5
  const escalation = async (count, steps) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmelab/gtd",
3
- "version": "64.0.0",
3
+ "version": "64.1.0",
4
4
  "private": false,
5
5
  "description": "Git-aware CLI that emits the next prompt for an autonomous coding agent based on the current repository state",
6
6
  "bin": {
package/src/cli/Cli.ts CHANGED
@@ -325,6 +325,16 @@ const FLAGS: readonly FlagRow[] = [
325
325
  valueHint: "",
326
326
  help: "Instead of <mode>'s findings, list the open threads in <file>; exits 1 when any are open. Alone, lists the open code-comment threads in the files the current process changed",
327
327
  },
328
+ {
329
+ name: "--spec",
330
+ arity: 1,
331
+ repeatable: false,
332
+ scope: (kind) => kind === "check",
333
+ decode: ([raw]) => Result.succeed(raw ?? ""),
334
+ scopeError: "gtd: --spec is only valid for `gtd check`",
335
+ valueHint: "<json>",
336
+ help: "Check against the declared format this spec describes, named <mode>, instead of a built-in one; validation scripts pass it for a step whose mode is a declared format",
337
+ },
328
338
  {
329
339
  name: "--verbose",
330
340
  arity: 0,
@@ -587,7 +597,7 @@ The fixed answerer reads --answers and leaves out the questions it does not cove
587
597
  arity: { names: ["mode", "file"] },
588
598
  group: "Driver plumbing",
589
599
  summary: "Check a steering file's format",
590
- details: `Run the steering format <mode> (qa, review or findings) over <file> and print each finding on a line of its own; exits 1 when there are any, or when <file> does not exist. An unknown <mode> is a usage error (exit 2). Reads no workflow and runs from any directory — emitted validation scripts call it.`,
600
+ details: `Run the steering format <mode> (qa, review or findings, or the declared format --spec describes) over <file> and print each finding on a line of its own; exits 1 when there are any, or when <file> does not exist. An unknown <mode> is a usage error (exit 2). Reads no workflow and runs from any directory — emitted validation scripts call it.`,
591
601
  },
592
602
  {
593
603
  token: "uncheck",
@@ -1124,6 +1134,7 @@ export const parseArgv = (argv: readonly string[]): CliPlan => {
1124
1134
  readonly "--threshold"?: Readonly<Record<string, string>>
1125
1135
  readonly "--open-questions"?: boolean
1126
1136
  readonly "--open-threads"?: boolean
1137
+ readonly "--spec"?: string
1127
1138
  readonly "--host"?: string
1128
1139
  readonly "--self-signed"?: boolean
1129
1140
  readonly "--dev"?: boolean
@@ -1199,6 +1210,7 @@ export const parseArgv = (argv: readonly string[]): CliPlan => {
1199
1210
  ? { openQuestions: bag["--open-questions"] }
1200
1211
  : {}),
1201
1212
  ...(bag["--open-threads"] !== undefined ? { openThreads: bag["--open-threads"] } : {}),
1213
+ ...(bag["--spec"] !== undefined ? { spec: bag["--spec"] } : {}),
1202
1214
  },
1203
1215
  json,
1204
1216
  verbose,
@@ -70,6 +70,7 @@ import { formatCommitMessage, PIN_STATES } from "../replay/index.js"
70
70
  import { planStart, planStep as planStepPure, type JudgeVerdict } from "../step/index.js"
71
71
  import {
72
72
  BUILT_IN_MODE_NAMES,
73
+ formatFromSpec,
73
74
  steeringFormatFor,
74
75
  openThreads,
75
76
  parseCodeThreads,
@@ -169,6 +170,8 @@ export type Command =
169
170
  readonly file?: string
170
171
  readonly openQuestions?: boolean
171
172
  readonly openThreads?: boolean
173
+ /** A declared format's spec: `mode` names it, and no built-in is looked up. */
174
+ readonly spec?: string
172
175
  }
173
176
  | { readonly kind: "uncheck"; readonly file: string }
174
177
  | { readonly kind: "guide" }
@@ -903,8 +906,9 @@ const selfValidateCommand = (
903
906
  state: string,
904
907
  mode: StateMode,
905
908
  file: string,
909
+ spec: string | undefined,
906
910
  ): string => {
907
- const resolved = resolveMode(def, state, mode)
911
+ const resolved = resolveMode(def, state, mode, spec)
908
912
  const validate = resolved.kind === "resolved" ? resolved.validate : undefined
909
913
  const command = validate?.kind === "command" ? validate.command : seededValidateCommand(mode)
910
914
  return commandForFile(command, file)
@@ -934,7 +938,7 @@ const resolveValidateScript = (
934
938
  const mode = rest.stepDef.mode
935
939
  if (file === undefined || mode === undefined) return undefined
936
940
 
937
- const resolved = resolveMode(rest.def, rest.state, mode)
941
+ const resolved = resolveMode(rest.def, rest.state, mode, rest.stepDef.modeSpec)
938
942
  if (resolved.kind === "unknown") {
939
943
  return yield* Effect.fail(new Error(resolved.message))
940
944
  }
@@ -976,7 +980,13 @@ const runNextCommand = (
976
980
  yield* writeSelection(out, fields, json.path)
977
981
  } else {
978
982
  const validateCommand = emitsValidatablePrompt(rendered)
979
- ? selfValidateCommand(rest.def, rest.state, rendered.mode!, rendered.file!)
983
+ ? selfValidateCommand(
984
+ rest.def,
985
+ rest.state,
986
+ rendered.mode!,
987
+ rendered.file!,
988
+ rest.stepDef.modeSpec,
989
+ )
980
990
  : undefined
981
991
  if (DEMAND_BRIEFING[fields.kind].suppressHeader) {
982
992
  // A prompt's stdout is the agent's input, so its header goes to stderr;
@@ -1019,9 +1029,10 @@ const runCheckCommand = (
1019
1029
  file: string,
1020
1030
  openQuestions: boolean,
1021
1031
  openThreadsFlag: boolean,
1032
+ spec: string | undefined,
1022
1033
  ): Effect.Effect<void, Error, Workspace> =>
1023
1034
  Effect.gen(function* () {
1024
- const format = steeringFormatFor(mode)
1035
+ const format = spec !== undefined ? yield* declaredFormat(spec) : steeringFormatFor(mode)
1025
1036
  if (format === undefined) {
1026
1037
  return yield* Effect.fail(
1027
1038
  new GtdUsageError(
@@ -1071,6 +1082,13 @@ const runCheckCommand = (
1071
1082
  )
1072
1083
  })
1073
1084
 
1085
+ const declaredFormat = (spec: string): Effect.Effect<SteeringFormat, GtdUsageError> =>
1086
+ Effect.try({
1087
+ try: () => formatFromSpec(spec),
1088
+ catch: (error) =>
1089
+ new GtdUsageError(`gtd check: --spec is not a declared format: ${(error as Error).message}`),
1090
+ })
1091
+
1074
1092
  const existingFile = (file: string): Effect.Effect<string, Error, Workspace> =>
1075
1093
  Effect.flatMap(Workspace, (workspace) => {
1076
1094
  const content = workspace.atPath(file)
@@ -1384,6 +1402,7 @@ const dispatchVoidCommand = (
1384
1402
  command.file,
1385
1403
  command.openQuestions ?? false,
1386
1404
  command.openThreads ?? false,
1405
+ command.spec,
1387
1406
  )
1388
1407
  case "uncheck":
1389
1408
  return runUncheckCommand(command.file)
package/src/edge/Edge.ts CHANGED
@@ -705,7 +705,7 @@ const DEFAULT_JUDGE_MESSAGE =
705
705
  const optional = <K extends string, V>(key: K, value: V | undefined): { [P in K]?: V } =>
706
706
  (value === undefined ? {} : { [key]: value }) as { [P in K]?: V }
707
707
 
708
- type StepCommon = Pick<StepDef, "actor" | "label" | "file" | "mode">
708
+ type StepCommon = Pick<StepDef, "actor" | "label" | "file" | "mode" | "modeSpec">
709
709
 
710
710
  const commonOf = (step: ReachedStep): StepCommon => {
711
711
  const options = step.request.options
@@ -713,7 +713,9 @@ const commonOf = (step: ReachedStep): StepCommon => {
713
713
  actor: step.actor,
714
714
  ...optional("label", options.label),
715
715
  ...optional("file", options.file),
716
- ...optional("mode", options.mode),
716
+ ...(typeof options.mode === "object"
717
+ ? { mode: options.mode.name, modeSpec: options.mode.spec }
718
+ : optional("mode", options.mode)),
717
719
  }
718
720
  }
719
721
 
@@ -833,6 +835,23 @@ const hintsOf = (def: StepDef): RestHints => ({
833
835
  ...optional("access", def.access),
834
836
  })
835
837
 
838
+ /** A step's mode is a name the workflow knows, or a declared format named apart from all of them. */
839
+ const modeFaultOf = (
840
+ def: WorkflowDefinition,
841
+ stepName: string,
842
+ stepDef: StepDef,
843
+ ): string | undefined => {
844
+ if (stepDef.mode === undefined) return undefined
845
+ const known = def.modes[stepDef.mode] !== undefined
846
+ if (stepDef.modeSpec !== undefined)
847
+ return known
848
+ ? `gtd: config: step "${stepName}": declared format "${stepDef.mode}" has the name of a mode this workflow already knows — rename the format`
849
+ : undefined
850
+ return known
851
+ ? undefined
852
+ : `gtd: config: step "${stepName}": mode "${stepDef.mode}" is not a mode this workflow knows (${knownModes(def).join(", ")})`
853
+ }
854
+
836
855
  const normalizeStatus = (raw: string): ChangeStatus => (raw === "A" ? "A" : raw === "D" ? "D" : "M")
837
856
 
838
857
  /** The currently rested step and its description — enough for the viewer and the LSP. */
@@ -997,13 +1016,8 @@ export const restAt = (
997
1016
  const { reached, step } = resting
998
1017
  const { stepDef, state, memory } = restFacts(pin, step, reached.trace, run)
999
1018
  yield* (yield* Narrator).narrate(`rest resolved: ${state} (awaits ${stepDef.actor})`)
1000
- if (stepDef.mode !== undefined && def.modes[stepDef.mode] === undefined) {
1001
- return yield* Effect.fail(
1002
- new Error(
1003
- `gtd: config: step "${step.name}": mode "${stepDef.mode}" is not a mode this workflow knows (${knownModes(def).join(", ")})`,
1004
- ),
1005
- )
1006
- }
1019
+ const modeFault = modeFaultOf(def, step.name, stepDef)
1020
+ if (modeFault !== undefined) return yield* Effect.fail(new Error(modeFault))
1007
1021
  const changes =
1008
1022
  ref === undefined
1009
1023
  ? (yield* pendingChanges(git, stepDef.approve === true)).map((e) => ({
@@ -1,12 +1,18 @@
1
1
  import { Effect, Path } from "effect"
2
2
  import { Host, type HostOps } from "../platform/index.js"
3
3
  import {
4
+ declaredValidateCommand,
4
5
  isSeededValidateCommand,
5
6
  knownModes,
6
7
  type StateMode,
7
8
  type WorkflowDefinition,
8
9
  } from "../workflow/index.js"
9
- import { steeringFormatFor, type SteeringFinding, type SteeringFormat } from "../steering/index.js"
10
+ import {
11
+ formatFromSpec,
12
+ steeringFormatFor,
13
+ type SteeringFinding,
14
+ type SteeringFormat,
15
+ } from "../steering/internal.js"
10
16
  import { buildModeContradictionCheck, modeContradictionSkipNotice } from "./ModeContradiction.js"
11
17
  import {
12
18
  binaryGuard,
@@ -101,12 +107,26 @@ const capabilitiesFor = (
101
107
  * The `"unknown"` arm carries the whole refusal message, naming what IS known
102
108
  * when `def` is given — this is the one place that message is built, so it
103
109
  * can never drift from what actually resolved.
110
+ *
111
+ * `spec` is a declared format's: it alone decides, validating through
112
+ * `gtd check --spec` with no `format:` half.
104
113
  */
105
114
  export const resolveMode = (
106
115
  def: Pick<WorkflowDefinition, "modes"> | undefined,
107
116
  state: string,
108
117
  mode: StateMode,
118
+ spec?: string,
109
119
  ): ModeResolution => {
120
+ if (spec !== undefined) {
121
+ const format = formatFromSpec(spec)
122
+ return {
123
+ kind: "resolved",
124
+ mode,
125
+ format,
126
+ validate: { kind: "command", command: declaredValidateCommand(mode, spec) },
127
+ capabilities: { format, liveValidate: format.validate },
128
+ }
129
+ }
110
130
  const declared = def?.modes?.[mode]
111
131
  const format = steeringFormatFor(mode)
112
132
  if (declared === undefined && format === undefined) {
@@ -50,6 +50,7 @@ export {
50
50
  type Judgment,
51
51
  type KeptTip,
52
52
  type Mark,
53
+ type DeclaredMode,
53
54
  type ModeName,
54
55
  type OpenQuestion,
55
56
  type RunOptions,
@@ -19,6 +19,7 @@ import {
19
19
  type AgentOptions,
20
20
  type HumanOptions,
21
21
  type Judgment,
22
+ type DeclaredMode,
22
23
  type ModeName,
23
24
  type RunOptions,
24
25
  type Settings,
@@ -315,6 +316,6 @@ describe("mode", () => {
315
316
  expectTypeOf<"qa">().toMatchTypeOf<ModeName>()
316
317
  expectTypeOf<string>().toMatchTypeOf<ModeName>()
317
318
  expectTypeOf<ModeName>().toMatchTypeOf<string>()
318
- expectTypeOf<SteeringOptions["mode"]>().toEqualTypeOf<ModeName | undefined>()
319
+ expectTypeOf<SteeringOptions["mode"]>().toEqualTypeOf<ModeName | DeclaredMode | undefined>()
319
320
  })
320
321
  })
@@ -8,6 +8,12 @@ import type { WorkflowBuild } from "./factory.js"
8
8
  /** A steering-file mode: a built-in format's name or a `config.modes` entry. */
9
9
  export type ModeName = "qa" | "review" | "findings" | (string & {})
10
10
 
11
+ /** A format declared with `listFormat`/`sectionFormat` from `@pmelab/gtd/steering`, used as a step's mode. */
12
+ export interface DeclaredMode {
13
+ readonly name: string
14
+ readonly spec: string
15
+ }
16
+
11
17
  interface SteeringCommon {
12
18
  /** Display name for drivers and viewers. */
13
19
  readonly label?: string | undefined
@@ -24,8 +30,8 @@ type SteeringFile =
24
30
  }
25
31
  | {
26
32
  readonly file: string
27
- /** The steering file's mode — a built-in name or a `modes:` entry. Requires `file`. */
28
- readonly mode: ModeName
33
+ /** The steering file's mode — a built-in name, a `modes:` entry, or a declared format. Requires `file`. */
34
+ readonly mode: ModeName | DeclaredMode
29
35
  }
30
36
 
31
37
  /** A steering-file declaration shared by the steps that rest on one. `mode` requires `file`. */
@@ -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
+ })