@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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +12 -11
- package/dist/chunks/{flows-DENt0uWV.mjs → flows-B7ZRCe-B.mjs} +1 -1
- package/dist/chunks/{rolldown-runtime-8H4AJuhK.mjs → rolldown-runtime-D7D4PA-g.mjs} +0 -1
- package/dist/chunks/{steering-DF_BMadH.mjs → steering-Cem5i6Xl.mjs} +422 -2
- package/dist/chunks/{testkit-DwypA7cH.mjs → testkit-DfWZQCWQ.mjs} +1073 -328
- package/dist/flows.mjs +1 -1
- package/dist/gtd.bundle.mjs +4372 -1310
- package/dist/gtd.core.mjs +126 -118
- package/dist/steering.mjs +2 -2
- package/dist/testkit/gherkin.mjs +2 -2
- package/dist/testkit.mjs +2 -2
- package/dist/workflow-kit.mjs +2 -2
- package/package.json +1 -1
- package/src/cli/Cli.test.ts +5 -8
- package/src/cli/Cli.ts +22 -28
- package/src/cli/Output.ts +2 -1
- package/src/cli/Program.test.ts +55 -53
- package/src/cli/Program.ts +34 -34
- package/src/cli/ToolCommands.ts +3 -4
- package/src/edge/Edge.test.ts +38 -12
- package/src/edge/Edge.ts +53 -19
- package/src/emit/SteeringMode.test.ts +4 -2
- package/src/emit/SteeringMode.ts +31 -10
- package/src/flows/index.ts +1 -0
- package/src/flows/runtime.test.ts +2 -1
- package/src/flows/runtime.ts +8 -2
- package/src/gherkin/messages.test.ts +1 -1
- package/src/gherkin/module-config.feature +1 -1
- package/src/gherkin/testkit.feature +2 -2
- package/src/install/Version.ts +14 -13
- package/src/platform/Host.ts +75 -22
- package/src/platform/NodePinFile.ts +44 -0
- package/src/platform/NodeRuntime.ts +19 -0
- package/src/platform/NodeSync.ts +64 -0
- package/src/platform/PinFile.ts +0 -50
- package/src/platform/Sha.ts +121 -0
- package/src/platform/SyncIo.ts +35 -0
- package/src/platform/Workspace.test.ts +8 -3
- package/src/platform/Workspace.ts +82 -123
- package/src/platform/WorktreeState.test.ts +8 -4
- package/src/platform/WorktreeState.ts +21 -19
- package/src/platform/index.ts +18 -5
- package/src/replay/Format.test.ts +16 -13
- package/src/replay/Format.ts +4 -2
- package/src/replay/Replay.test.ts +25 -5
- package/src/replay/Replay.ts +10 -6
- package/src/replay/Trailers.test.ts +10 -10
- package/src/replay/Trailers.ts +23 -1
- package/src/replay/index.ts +1 -0
- 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/step/planStart.test.ts +2 -2
- package/src/step/planStep.test.ts +4 -4
- package/src/testkit/TestRepo.test.ts +1 -1
- package/src/testkit/TestRepo.ts +4 -18
- package/src/twins/EmittedScriptRecognizer.ts +5 -3
- package/src/twins/EmittedScripts.test.ts +19 -0
- package/src/twins/Host.test.ts +151 -0
- package/src/twins/Layers.test.ts +2 -2
- package/src/twins/Layers.ts +44 -18
- package/src/twins/Path.test.ts +52 -0
- package/src/twins/SyncIo.test.ts +153 -0
- package/src/twins/SyncIo.ts +159 -0
- package/src/twins/cliIo.ts +8 -1
- package/src/twins/index.ts +5 -0
- package/src/workflow/SteeringFormats.ts +4 -0
- package/src/workflow/Workflow.ts +2 -0
- package/src/workflow/discovery.test.ts +6 -5
- package/src/workflow/discovery.ts +43 -32
- package/src/workflow/fingerprint.test.ts +23 -3
- package/src/workflow/fingerprint.ts +9 -11
- package/src/workflow/index.ts +5 -1
- package/src/workflow/load.test.ts +16 -6
- package/src/workflow/load.ts +107 -69
- package/src/workflow/pin.test.ts +3 -2
- 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)
|
package/src/replay/Replay.ts
CHANGED
|
@@ -29,10 +29,14 @@ import {
|
|
|
29
29
|
type CommitMessage,
|
|
30
30
|
type JudgeVerdict,
|
|
31
31
|
type StepId,
|
|
32
|
-
|
|
32
|
+
ENGINE_PREFIX,
|
|
33
|
+
pinStateIn,
|
|
33
34
|
} from "./Trailers.js"
|
|
34
35
|
|
|
35
|
-
const
|
|
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 ||
|
|
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 &&
|
|
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 (
|
|
723
|
-
return `gtd: step "${scoped(request.name)}": the
|
|
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:
|
|
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:
|
|
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:
|
|
85
|
-
`Gtd-Version: ${value}\nGtd-Format:
|
|
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:
|
|
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:
|
|
109
|
-
expect(parseCommitMessage(message).format).toBe(
|
|
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:
|
|
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(
|
|
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:
|
|
292
|
+
expect(lines).toContain("Gtd-Format: 4")
|
|
293
293
|
})
|
|
294
294
|
|
|
295
295
|
it("writes nothing without marks", () => {
|
package/src/replay/Trailers.ts
CHANGED
|
@@ -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>> = {
|
|
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
|
package/src/replay/index.ts
CHANGED
|
@@ -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
|
+
})
|