@pmelab/gtd 23.2.0 → 24.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 +44 -11
- package/claude/hooks/access.ts +1 -1
- package/dist/chunks/Schema-Dh-mkwkq.mjs +25353 -0
- package/dist/chunks/reserved-ejn-LVYO.mjs +1388 -0
- package/dist/chunks/rolldown-runtime-8H4AJuhK.mjs +14 -0
- package/dist/chunks/steering-D-PvRC5W.mjs +16296 -0
- package/dist/chunks/wire-DA7q2ikx.mjs +473 -0
- package/dist/flows.mjs +525 -0
- package/dist/gtd.bundle.mjs +43830 -56044
- package/dist/steering.mjs +2 -0
- package/dist/testkit.mjs +13006 -0
- package/dist/wire.mjs +2 -0
- package/dist/workflow-kit.mjs +352 -0
- package/dist/workflows/feature.mjs +315 -0
- package/dist/workflows/fix.mjs +40 -0
- package/dist/workflows/review.mjs +25 -0
- package/dist/workflows.mjs +1953 -1724
- package/package.json +70 -8
- package/skills/authoring/SKILL.md +5 -4
- package/src/cli/Cli.test.ts +1329 -0
- package/src/cli/Cli.ts +1348 -0
- package/src/cli/Program.test.ts +2262 -0
- package/src/cli/Program.ts +1331 -0
- package/src/cli/ToolCommands.ts +43 -0
- package/src/cli/index.ts +14 -0
- package/src/edge/Edge.test.ts +1016 -0
- package/src/edge/Edge.ts +1225 -0
- package/src/edge/index.ts +16 -0
- package/src/emit/Emit.test.ts +425 -0
- package/src/emit/Emit.ts +139 -0
- package/src/emit/GitScript.test.ts +260 -0
- package/src/emit/GitScript.ts +131 -0
- package/src/emit/ModeContradiction.test.ts +120 -0
- package/src/emit/ModeContradiction.ts +66 -0
- package/src/emit/OutcomeScript.test.ts +238 -0
- package/src/emit/OutcomeScript.ts +73 -0
- package/src/emit/SteeringMode.test.ts +274 -0
- package/src/emit/SteeringMode.ts +256 -0
- package/src/emit/index.ts +40 -0
- package/src/flows/factory.ts +5 -1
- package/src/flows/runtime.ts +3 -0
- package/src/install/ConfigScaffold.ts +19 -0
- package/src/install/Install.test.ts +426 -0
- package/src/install/Install.ts +634 -0
- package/src/install/Version.ts +27 -0
- package/src/install/index.ts +3 -0
- package/src/platform/CommandRunner.ts +74 -0
- package/src/platform/Commentary.test.ts +126 -0
- package/src/platform/Commentary.ts +92 -0
- package/src/platform/Git.test.ts +50 -0
- package/src/platform/Git.ts +552 -0
- package/src/platform/Host.ts +42 -0
- package/src/platform/RetainedHistory.test.ts +65 -0
- package/src/platform/RetainedHistory.ts +34 -0
- package/src/platform/Workspace.test.ts +669 -0
- package/src/platform/Workspace.ts +631 -0
- package/src/platform/WorktreeState.test.ts +162 -0
- package/src/platform/WorktreeState.ts +50 -0
- package/src/platform/index.ts +22 -0
- package/src/replay/Access.test.ts +53 -0
- package/src/replay/Access.ts +46 -0
- package/src/replay/Format.test.ts +20 -0
- package/src/replay/Format.ts +14 -0
- package/src/replay/Replay.test.ts +887 -0
- package/src/replay/Replay.ts +669 -0
- package/src/replay/Trailers.test.ts +117 -0
- package/src/replay/Trailers.ts +213 -0
- package/src/replay/Tree.test.ts +25 -0
- package/src/replay/Tree.ts +45 -0
- package/src/replay/index.ts +11 -0
- package/src/steering/Blocks.fixture.ts +6 -0
- package/src/steering/Blocks.test.ts +167 -0
- package/src/steering/Blocks.ts +321 -0
- package/src/steering/CodeThreads.test.ts +127 -0
- package/src/steering/CodeThreads.ts +197 -0
- package/src/steering/Edits.test.ts +19 -0
- package/src/steering/Edits.ts +20 -0
- package/src/steering/Eol.test.ts +16 -0
- package/src/steering/Eol.ts +2 -0
- package/src/steering/FORMATS.md +58 -0
- package/src/steering/Footnotes.test.ts +977 -0
- package/src/steering/Footnotes.ts +661 -0
- package/src/steering/Inline.fixture.ts +7 -0
- package/src/steering/Inline.test.ts +227 -0
- package/src/steering/Inline.ts +195 -0
- package/src/steering/MarkdownTree.test.ts +247 -0
- package/src/steering/MarkdownTree.ts +260 -0
- package/src/steering/Outline.ts +19 -0
- package/src/steering/SteeringFormat.test.ts +91 -0
- package/src/steering/SteeringFormat.ts +267 -0
- package/src/steering/SteeringFormats.test.ts +163 -0
- package/src/steering/Threads.ts +163 -0
- package/src/steering/contract.test.ts +264 -0
- package/src/steering/fixtures/code-threads/cache.stripped.ts.txt +6 -0
- package/src/steering/fixtures/code-threads/cache.ts.md +15 -0
- package/src/steering/fixtures/free-form/notes.md +12 -0
- package/src/steering/fixtures/free-form/notes.read.json +42 -0
- package/src/steering/fixtures/qa/answered.md +23 -0
- package/src/steering/fixtures/qa/answered.read.json +119 -0
- package/src/steering/fixtures/qa/requirements.md +160 -0
- package/src/steering/fixtures/qa/requirements.read.json +475 -0
- package/src/steering/fixtures/review/review.md +76 -0
- package/src/steering/fixtures/review/review.read.json +237 -0
- package/src/steering/fixtures/review/ticked.md +23 -0
- package/src/steering/fixtures/review/ticked.read.json +99 -0
- package/src/steering/fixtures/review-notes/baseline.md +14 -0
- package/src/steering/fixtures/review-notes/noted.md +19 -0
- package/src/steering/freeform.test.ts +311 -0
- package/src/steering/freeform.ts +148 -0
- package/src/steering/index.test.ts +62 -0
- package/src/steering/index.ts +140 -0
- package/src/steering/qa.test.ts +2160 -0
- package/src/steering/qa.ts +1104 -0
- package/src/steering/review.test.ts +1829 -0
- package/src/steering/review.ts +1180 -0
- package/src/step/LandStep.ts +31 -0
- package/src/step/RepoSnapshot.ts +29 -0
- package/src/step/accessRefusal.test.ts +58 -0
- package/src/step/accessRefusal.ts +26 -0
- package/src/step/index.ts +5 -0
- package/src/step/planStart.test.ts +202 -0
- package/src/step/planStart.ts +167 -0
- package/src/step/planStep.test.ts +104 -0
- package/src/step/planStep.ts +89 -0
- package/src/step/snapshot.fixture.ts +28 -0
- package/src/testkit/EmittedScriptRecognizer.test.ts +639 -0
- package/src/testkit/EmittedScriptRecognizer.ts +642 -0
- package/src/testkit/FakeGitOperations.ts +95 -0
- package/src/testkit/GitTiers.test.ts +11 -0
- package/src/testkit/GitTiers.ts +1052 -0
- package/src/testkit/InMemRepo.ts +467 -0
- package/src/testkit/Layers.test.ts +58 -0
- package/src/testkit/Layers.ts +241 -0
- package/src/testkit/StrictGitOperations.ts +31 -0
- package/src/testkit/TestRepo.test.ts +119 -0
- package/src/testkit/TestRepo.ts +274 -0
- package/src/testkit/cliIo.ts +36 -0
- package/src/testkit/index.ts +9 -0
- package/src/testkit/steps.ts +177 -0
- package/src/testkit/testkit.feature +65 -0
- package/src/wire/BeatDocument.test.ts +419 -0
- package/src/wire/BeatDocument.ts +114 -0
- package/src/wire/BeatPlain.test.ts +113 -0
- package/src/wire/BeatPlain.ts +127 -0
- package/src/wire/BeatStatus.test.ts +59 -0
- package/src/wire/BeatStatus.ts +81 -0
- package/src/wire/Demand.test.ts +142 -0
- package/src/wire/Demand.ts +108 -0
- package/src/wire/ExitCodes.test.ts +21 -0
- package/src/wire/ExitCodes.ts +16 -0
- package/src/wire/Glob.ts +29 -0
- package/src/wire/Judge.ts +22 -0
- package/src/wire/Land.test.ts +120 -0
- package/src/wire/Land.ts +75 -0
- package/src/wire/Select.test.ts +148 -0
- package/src/wire/Select.ts +78 -0
- package/src/wire/Sessions.test.ts +59 -0
- package/src/wire/Sessions.ts +39 -0
- package/src/wire/WireSchema.test.ts +162 -0
- package/src/wire/constants.ts +22 -0
- package/src/wire/golden/1/beat-capture.json +34 -0
- package/src/wire/golden/1/beat-message.json +34 -0
- package/src/wire/golden/1/beat-prompt.json +44 -0
- package/src/wire/golden/1/beat-script.json +34 -0
- package/src/wire/golden/1/beat-stalled.json +34 -0
- package/src/wire/golden/1/judge.json +14 -0
- package/src/wire/golden/1/land-noop.json +10 -0
- package/src/wire/golden/1/land.json +10 -0
- package/src/wire/index.ts +12 -0
- package/src/wire/types.ts +46 -0
- package/src/workflow/Diagnostic.test.ts +93 -0
- package/src/workflow/Diagnostic.ts +54 -0
- package/src/workflow/SteeringFormats.test.ts +27 -0
- package/src/workflow/SteeringFormats.ts +6 -0
- package/src/workflow/Workflow.ts +77 -0
- package/src/workflow/compile.test.ts +53 -0
- package/src/workflow/compile.ts +56 -0
- package/src/workflow/discovery.ts +122 -0
- package/src/workflow/fingerprint.test.ts +52 -0
- package/src/workflow/fingerprint.ts +53 -0
- package/src/workflow/index.ts +33 -0
- package/src/workflow/load.test.ts +1017 -0
- package/src/workflow/load.ts +836 -0
- package/src/workflow/skills.test.ts +109 -0
- package/src/workflow/skills.ts +90 -0
- package/src/workflow/vars.ts +33 -0
- package/src/workflow-kit/health.test.ts +30 -0
- package/src/workflow-kit/health.ts +86 -0
- package/src/workflow-kit/index.ts +7 -0
- package/src/workflow-kit/quality.test.ts +17 -0
- package/src/workflow-kit/quality.ts +45 -0
- package/src/workflow-kit/review.ts +324 -0
- package/src/{workflows → workflow-kit}/scenarios.ts +21 -28
- package/src/workflows/feature/feature.test.ts +32 -0
- package/src/workflows/{bundled.ts → feature/feature.ts} +11 -63
- package/src/workflows/feature/index.ts +10 -0
- package/src/workflows/{packages.test.ts → feature/packages.test.ts} +2 -2
- package/src/workflows/{packages.ts → feature/packages.ts} +3 -3
- package/src/workflows/{planning.ts → feature/planning.ts} +2 -3
- package/src/workflows/{review.test.ts → feature/review.test.ts} +9 -44
- package/src/workflows/feature/review.ts +106 -0
- package/src/workflows/feature/steps.test.ts +82 -0
- package/src/workflows/feature/steps.ts +92 -0
- package/src/workflows/fix/fix.feature +80 -0
- package/src/workflows/fix/fix.test.ts +74 -0
- package/src/workflows/fix/fix.ts +52 -0
- package/src/workflows/fix/index.ts +6 -0
- package/src/workflows/index.ts +36 -2
- package/src/workflows/review/index.ts +6 -0
- package/src/workflows/review/review.test.ts +99 -0
- package/src/workflows/review/review.ts +33 -0
- package/src/workflows/{access.test.ts → shared/access.test.ts} +1 -1
- package/src/workflows/{access.ts → shared/access.ts} +3 -5
- package/src/workflows/shared/config.fixture.ts +12 -0
- package/src/workflows/{health.test.ts → shared/health.test.ts} +1 -1
- package/src/workflows/{health.ts → shared/health.ts} +38 -55
- package/src/workflows/{options.test.ts → shared/options.test.ts} +2 -2
- package/src/workflows/{options.ts → shared/options.ts} +14 -2
- package/src/workflows/shared/reserved.ts +11 -0
- package/src/workflows/{scenarios.test.ts → shared/scenarios.test.ts} +1 -1
- package/src/workflows/shared/scenarios.ts +35 -0
- package/src/workflows/{skills.test.ts → shared/skills.test.ts} +3 -1
- package/src/workflows/{skills.ts → shared/skills.ts} +7 -6
- package/src/workflows/{steps.test.ts → shared/steps.test.ts} +1 -66
- package/src/workflows/{steps.ts → shared/steps.ts} +8 -98
- package/src/workflows/shared/tail.test.ts +205 -0
- package/src/workflows/shared/tail.ts +135 -0
- package/src/workflows/{text.fixture.ts → shared/text.fixture.ts} +2 -2
- package/src/workflows/{text.ts → shared/text.ts} +48 -7
- package/src/workflows/{threads.test.ts → shared/threads.test.ts} +1 -1
- package/src/workflows/shared/workflows.steps.ts +7 -0
- package/src/workflows/review.ts +0 -362
- /package/src/workflows/{prose.ts → shared/prose.ts} +0 -0
- /package/src/workflows/{text.test.ts → shared/text.test.ts} +0 -0
- /package/src/workflows/{vars.ts → shared/vars.ts} +0 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { formatCommitMessage, parseCommitMessage, parseSubject } from "./Trailers.js"
|
|
3
|
+
|
|
4
|
+
describe("the commit-message codec", () => {
|
|
5
|
+
it("round-trips a step commit's subject and trailers", () => {
|
|
6
|
+
const message = formatCommitMessage({
|
|
7
|
+
actor: "judge",
|
|
8
|
+
from: "health.judge",
|
|
9
|
+
to: "health.escalate",
|
|
10
|
+
step: { name: "health.judge", occurrence: 2 },
|
|
11
|
+
cost: { cost: 1450, model: "smart" },
|
|
12
|
+
judge: [{ id: "verdict", answer: "identical", p: 0.9 }],
|
|
13
|
+
})
|
|
14
|
+
expect(message.split("\n")[0]).toBe("gtd(judge): health.judge → health.escalate")
|
|
15
|
+
const parsed = parseCommitMessage(message)
|
|
16
|
+
expect(parsed.parsed).toEqual({ actor: "judge", from: "health.judge", to: "health.escalate" })
|
|
17
|
+
expect(parsed.step).toEqual({ name: "health.judge", occurrence: 2 })
|
|
18
|
+
expect(parsed.cost).toEqual([{ cost: 1450, model: "smart" }])
|
|
19
|
+
expect(parsed.judge).toEqual([{ id: "verdict", answer: "identical", p: 0.9 }])
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it("collapses a self-loop to the bare subject and keeps entry trailers", () => {
|
|
23
|
+
const message = formatCommitMessage({
|
|
24
|
+
actor: "human",
|
|
25
|
+
to: "review-gate.check",
|
|
26
|
+
reviewBase: "abc",
|
|
27
|
+
vars: { reviewBase: "main=x" },
|
|
28
|
+
})
|
|
29
|
+
const parsed = parseCommitMessage(message)
|
|
30
|
+
expect(parsed.subject).toBe("gtd(human): review-gate.check")
|
|
31
|
+
expect(parsed.step).toBeUndefined()
|
|
32
|
+
expect(parsed.reviewBase).toBe("abc")
|
|
33
|
+
expect(parsed.vars).toEqual({ reviewBase: "main=x" })
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it("round-trips the workflow trailer, written after Gtd-Step and before Gtd-Review-Base", () => {
|
|
37
|
+
const message = formatCommitMessage({
|
|
38
|
+
actor: "human",
|
|
39
|
+
to: "fix-precheck",
|
|
40
|
+
workflow: "fix",
|
|
41
|
+
reviewBase: "abc",
|
|
42
|
+
})
|
|
43
|
+
expect(message).toBe(
|
|
44
|
+
"gtd(human): fix-precheck\n\nGtd-Workflow: fix\nGtd-Review-Base: abc\nGtd-Format: 1",
|
|
45
|
+
)
|
|
46
|
+
expect(parseCommitMessage(message).workflow).toBe("fix")
|
|
47
|
+
expect(parseCommitMessage("gtd(human): fix-precheck").workflow).toBeUndefined()
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it("writes Gtd-Var trailers sorted by name, whatever the insertion order", () => {
|
|
51
|
+
const one = formatCommitMessage({ actor: "human", to: "x", vars: { b: "2", a: "1", c: "" } })
|
|
52
|
+
const two = formatCommitMessage({ actor: "human", to: "x", vars: { c: "", a: "1", b: "2" } })
|
|
53
|
+
expect(one).toBe(two)
|
|
54
|
+
expect(one).toBe("gtd(human): x\n\nGtd-Var: a=1\nGtd-Var: b=2\nGtd-Var: c=\nGtd-Format: 1")
|
|
55
|
+
expect(parseCommitMessage(one).vars).toEqual({ a: "1", b: "2", c: "" })
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it("writes Gtd-Config trailers sorted by path, after Gtd-Var, and reads paths with spaces back", () => {
|
|
59
|
+
const message = formatCommitMessage({
|
|
60
|
+
actor: "human",
|
|
61
|
+
to: "x",
|
|
62
|
+
vars: { a: "1" },
|
|
63
|
+
config: { "lib/my flow.ts": "0123456789abcdef", "gtd.config.ts": "deadbeefdeadbeef" },
|
|
64
|
+
})
|
|
65
|
+
expect(message).toBe(
|
|
66
|
+
"gtd(human): x\n\nGtd-Var: a=1\nGtd-Config: deadbeefdeadbeef gtd.config.ts\nGtd-Config: 0123456789abcdef lib/my flow.ts\nGtd-Format: 1",
|
|
67
|
+
)
|
|
68
|
+
expect(parseCommitMessage(message).config).toEqual({
|
|
69
|
+
"gtd.config.ts": "deadbeefdeadbeef",
|
|
70
|
+
"lib/my flow.ts": "0123456789abcdef",
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it("round-trips a Gtd-Config path that git or the trailer syntax would alter", () => {
|
|
75
|
+
const config = {
|
|
76
|
+
" lead.ts": "0123456789abcdef",
|
|
77
|
+
"trail.ts ": "0123456789abcdef",
|
|
78
|
+
'"q.ts': "0123456789abcdef",
|
|
79
|
+
}
|
|
80
|
+
const message = formatCommitMessage({ actor: "human", to: "x", config })
|
|
81
|
+
expect(message).toContain('Gtd-Config: 0123456789abcdef " lead.ts"')
|
|
82
|
+
expect(parseCommitMessage(message).config).toEqual(config)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it("ignores a Gtd-Config trailer without a 16-digit hex hash, without a path, or with a control character in its path", () => {
|
|
86
|
+
const read = (value: string) =>
|
|
87
|
+
parseCommitMessage(`gtd(human): x\n\nGtd-Config: ${value}`).config
|
|
88
|
+
expect(read("zzzzzzzzzzzzzzzz gtd.config.ts")).toEqual({})
|
|
89
|
+
expect(read("deadbeef gtd.config.ts")).toEqual({})
|
|
90
|
+
expect(read("deadbeefdeadbeef")).toEqual({})
|
|
91
|
+
expect(read("deadbeefdeadbeef a\x1b]0;x\x07.ts")).toEqual({})
|
|
92
|
+
expect(read('deadbeefdeadbeef "a\\u001b.ts"')).toEqual({})
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it("writes Gtd-Format last on every message, even one with no other trailer", () => {
|
|
96
|
+
const message = formatCommitMessage({ actor: "agent", to: "building" })
|
|
97
|
+
expect(message).toBe("gtd(agent): building\n\nGtd-Format: 1")
|
|
98
|
+
expect(parseCommitMessage(message).format).toBe(1)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it("reads a message without Gtd-Format as format 1, and an explicit one as written", () => {
|
|
102
|
+
expect(parseCommitMessage("gtd(agent): a → b\n\nGtd-Step: a#1").format).toBe(1)
|
|
103
|
+
expect(parseCommitMessage("gtd(agent): a → b\n\nGtd-Format: 2").format).toBe(2)
|
|
104
|
+
expect(parseCommitMessage("gtd(agent): a → b\n\nGtd-Format: two").format).toBe(1)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it("skips a malformed trailer instead of failing", () => {
|
|
108
|
+
const parsed = parseCommitMessage("gtd(judge): a → b\n\nGtd-Judge: {not json\nGtd-Step: nope")
|
|
109
|
+
expect(parsed.judge).toEqual([])
|
|
110
|
+
expect(parsed.step).toBeUndefined()
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it("parses only gtd subjects", () => {
|
|
114
|
+
expect(parseSubject("feat: add calculator")).toBeUndefined()
|
|
115
|
+
expect(parseSubject("gtd(agent): fix")).toEqual({ actor: "agent", to: "fix" })
|
|
116
|
+
})
|
|
117
|
+
})
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// The commit-message codec: the `gtd(<actor>): <from> → <to>` subject plus
|
|
2
|
+
// the `Gtd-*` trailers a step commit carries. A malformed trailer (never
|
|
3
|
+
// written by gtd) is skipped, not fatal — a corrupt line must never make every
|
|
4
|
+
// command unusable.
|
|
5
|
+
|
|
6
|
+
import { HISTORY_FORMAT } from "./Format.js"
|
|
7
|
+
|
|
8
|
+
const TRANSITION_SEP = " → "
|
|
9
|
+
|
|
10
|
+
export interface ParsedSubject {
|
|
11
|
+
readonly actor: string
|
|
12
|
+
readonly to: string
|
|
13
|
+
readonly from?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const SUBJECT_RE = /^gtd\(([^()]+)\): (.+)$/
|
|
17
|
+
|
|
18
|
+
/** `from` equal to `to` (or absent) collapses to the bare `gtd(<actor>): <to>` form. */
|
|
19
|
+
export const formatSubject = (actor: string, to: string, from?: string): string =>
|
|
20
|
+
from === undefined || from === to
|
|
21
|
+
? `gtd(${actor}): ${to}`
|
|
22
|
+
: `gtd(${actor}): ${from}${TRANSITION_SEP}${to}`
|
|
23
|
+
|
|
24
|
+
export const parseSubject = (subject: string): ParsedSubject | undefined => {
|
|
25
|
+
const match = SUBJECT_RE.exec(subject.trim())
|
|
26
|
+
const actor = match?.[1]
|
|
27
|
+
const rest = match?.[2]
|
|
28
|
+
if (actor === undefined || rest === undefined) return undefined
|
|
29
|
+
const sep = rest.lastIndexOf(TRANSITION_SEP)
|
|
30
|
+
if (sep === -1) return { actor, to: rest }
|
|
31
|
+
const from = rest.slice(0, sep)
|
|
32
|
+
const to = rest.slice(sep + TRANSITION_SEP.length)
|
|
33
|
+
if (from === "" || to === "") return undefined
|
|
34
|
+
return { actor, to, from }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface StepId {
|
|
38
|
+
readonly name: string
|
|
39
|
+
readonly occurrence: number
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const formatStepId = (id: StepId): string => `${id.name}#${id.occurrence}`
|
|
43
|
+
|
|
44
|
+
const STEP_ID_RE = /^(.+)#([1-9][0-9]*)$/
|
|
45
|
+
|
|
46
|
+
const parseStepId = (raw: string): StepId | undefined => {
|
|
47
|
+
const match = STEP_ID_RE.exec(raw.trim())
|
|
48
|
+
if (match === null) return undefined
|
|
49
|
+
return { name: match[1]!, occurrence: Number(match[2]) }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface JudgeVerdict {
|
|
53
|
+
readonly id: string
|
|
54
|
+
readonly answer: string | number | boolean
|
|
55
|
+
readonly p: number
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface CostEntry {
|
|
59
|
+
readonly cost: number
|
|
60
|
+
readonly model: string | undefined
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface CommitMessage {
|
|
64
|
+
readonly subject: string
|
|
65
|
+
readonly parsed: ParsedSubject | undefined
|
|
66
|
+
readonly step: StepId | undefined
|
|
67
|
+
/** Written only on a process's opening commit. */
|
|
68
|
+
readonly workflow: string | undefined
|
|
69
|
+
readonly judge: readonly JudgeVerdict[]
|
|
70
|
+
readonly vars: Readonly<Record<string, string>>
|
|
71
|
+
/** Written only on a process's first commit. */
|
|
72
|
+
readonly config: Readonly<Record<string, string>>
|
|
73
|
+
readonly reviewBase: string | undefined
|
|
74
|
+
readonly cost: readonly CostEntry[]
|
|
75
|
+
readonly format: number
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const isVerdict = (value: unknown): value is JudgeVerdict => {
|
|
79
|
+
if (typeof value !== "object" || value === null) return false
|
|
80
|
+
const v = value as Record<string, unknown>
|
|
81
|
+
return (
|
|
82
|
+
typeof v.id === "string" &&
|
|
83
|
+
["string", "number", "boolean"].includes(typeof v.answer) &&
|
|
84
|
+
typeof v.p === "number"
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const parseJson = (raw: string): unknown => {
|
|
89
|
+
try {
|
|
90
|
+
return JSON.parse(raw) as unknown
|
|
91
|
+
} catch {
|
|
92
|
+
return undefined
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const TRAILER_RE = /^(Gtd-[A-Za-z-]+):[ \t]*(.*?)[ \t]*$/gm
|
|
97
|
+
const COST_RE = /^([0-9]+(?:\.[0-9]+)?)(?:[ \t]+(.+))?$/
|
|
98
|
+
const VAR_RE = /^([^=\s]+)=(.*)$/
|
|
99
|
+
const CONFIG_RE = /^([0-9a-f]{16}) (.+)$/
|
|
100
|
+
// A path git's message cleanup or the trailer syntax would alter travels as a
|
|
101
|
+
// JSON string. A control character is refused on read: history written by
|
|
102
|
+
// someone else must never put escape sequences on a terminal.
|
|
103
|
+
// eslint-disable-next-line no-control-regex -- the control range IS the point
|
|
104
|
+
const CONTROL_CHARACTER = /[\x00-\x1f\x7f-\x9f]/
|
|
105
|
+
const encodePath = (path: string): string =>
|
|
106
|
+
path.trim() !== path || path.startsWith('"') || CONTROL_CHARACTER.test(path)
|
|
107
|
+
? JSON.stringify(path)
|
|
108
|
+
: path
|
|
109
|
+
const decodePath = (raw: string): string | undefined => {
|
|
110
|
+
const path = raw.startsWith('"') ? parseJson(raw) : raw
|
|
111
|
+
return typeof path === "string" && path !== "" && !CONTROL_CHARACTER.test(path) ? path : undefined
|
|
112
|
+
}
|
|
113
|
+
const FORMAT_RE = /^[1-9][0-9]*$/
|
|
114
|
+
|
|
115
|
+
interface Trailers {
|
|
116
|
+
step: StepId | undefined
|
|
117
|
+
workflow: string | undefined
|
|
118
|
+
reviewBase: string | undefined
|
|
119
|
+
readonly judge: JudgeVerdict[]
|
|
120
|
+
readonly vars: Record<string, string>
|
|
121
|
+
readonly config: Record<string, string>
|
|
122
|
+
readonly cost: CostEntry[]
|
|
123
|
+
format: number | undefined
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const TRAILER_READERS: Readonly<Record<string, (value: string, into: Trailers) => void>> = {
|
|
127
|
+
"Gtd-Step": (value, into) => {
|
|
128
|
+
into.step ??= parseStepId(value)
|
|
129
|
+
},
|
|
130
|
+
"Gtd-Workflow": (value, into) => {
|
|
131
|
+
if (value !== "") into.workflow ??= value
|
|
132
|
+
},
|
|
133
|
+
"Gtd-Judge": (value, into) => {
|
|
134
|
+
const parsed = parseJson(value)
|
|
135
|
+
if (isVerdict(parsed)) into.judge.push(parsed)
|
|
136
|
+
},
|
|
137
|
+
"Gtd-Var": (value, into) => {
|
|
138
|
+
const kv = VAR_RE.exec(value)
|
|
139
|
+
if (kv !== null) into.vars[kv[1]!] = kv[2]!
|
|
140
|
+
},
|
|
141
|
+
"Gtd-Config": (value, into) => {
|
|
142
|
+
const entry = CONFIG_RE.exec(value)
|
|
143
|
+
const path = entry === null ? undefined : decodePath(entry[2]!)
|
|
144
|
+
if (path !== undefined) into.config[path] = entry![1]!
|
|
145
|
+
},
|
|
146
|
+
"Gtd-Review-Base": (value, into) => {
|
|
147
|
+
into.reviewBase ??= value.split(/\s/)[0]
|
|
148
|
+
},
|
|
149
|
+
"Gtd-Format": (value, into) => {
|
|
150
|
+
if (FORMAT_RE.test(value)) into.format ??= Number(value)
|
|
151
|
+
},
|
|
152
|
+
"Gtd-Cost": (value, into) => {
|
|
153
|
+
const c = COST_RE.exec(value)
|
|
154
|
+
if (c !== null) into.cost.push({ cost: Number(c[1]), model: c[2]?.trim() || undefined })
|
|
155
|
+
},
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export const parseCommitMessage = (message: string): CommitMessage => {
|
|
159
|
+
const newline = message.indexOf("\n")
|
|
160
|
+
const subject = (newline === -1 ? message : message.slice(0, newline)).trim()
|
|
161
|
+
const trailers: Trailers = {
|
|
162
|
+
step: undefined,
|
|
163
|
+
workflow: undefined,
|
|
164
|
+
reviewBase: undefined,
|
|
165
|
+
judge: [],
|
|
166
|
+
vars: {},
|
|
167
|
+
config: {},
|
|
168
|
+
cost: [],
|
|
169
|
+
format: undefined,
|
|
170
|
+
}
|
|
171
|
+
if (newline !== -1) {
|
|
172
|
+
for (const match of message.slice(newline).matchAll(TRAILER_RE)) {
|
|
173
|
+
TRAILER_READERS[match[1]!]?.(match[2]!, trailers)
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return { subject, parsed: parseSubject(subject), ...trailers, format: trailers.format ?? 1 }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export interface CommitSpec {
|
|
180
|
+
readonly actor: string
|
|
181
|
+
readonly to: string
|
|
182
|
+
readonly from?: string
|
|
183
|
+
readonly step?: StepId
|
|
184
|
+
readonly workflow?: string
|
|
185
|
+
readonly reviewBase?: string
|
|
186
|
+
readonly vars?: Readonly<Record<string, string>>
|
|
187
|
+
readonly config?: Readonly<Record<string, string>>
|
|
188
|
+
readonly cost?: { readonly cost: number; readonly model?: string }
|
|
189
|
+
readonly judge?: readonly JudgeVerdict[]
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const sortedEntries = (record: Readonly<Record<string, string>> = {}): [string, string][] =>
|
|
193
|
+
Object.entries(record).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
194
|
+
|
|
195
|
+
/** Trailer order is fixed so the same spec always yields the same bytes. */
|
|
196
|
+
export const formatCommitMessage = (spec: CommitSpec): string => {
|
|
197
|
+
const lines: string[] = []
|
|
198
|
+
if (spec.step !== undefined) lines.push(`Gtd-Step: ${formatStepId(spec.step)}`)
|
|
199
|
+
if (spec.workflow !== undefined) lines.push(`Gtd-Workflow: ${spec.workflow}`)
|
|
200
|
+
if (spec.reviewBase !== undefined) lines.push(`Gtd-Review-Base: ${spec.reviewBase}`)
|
|
201
|
+
for (const [name, value] of sortedEntries(spec.vars)) lines.push(`Gtd-Var: ${name}=${value}`)
|
|
202
|
+
for (const [path, hash] of sortedEntries(spec.config))
|
|
203
|
+
lines.push(`Gtd-Config: ${hash} ${encodePath(path)}`)
|
|
204
|
+
if (spec.cost !== undefined) {
|
|
205
|
+
lines.push(
|
|
206
|
+
`Gtd-Cost: ${spec.cost.cost}${spec.cost.model !== undefined ? ` ${spec.cost.model}` : ""}`,
|
|
207
|
+
)
|
|
208
|
+
}
|
|
209
|
+
for (const verdict of spec.judge ?? []) lines.push(`Gtd-Judge: ${JSON.stringify(verdict)}`)
|
|
210
|
+
lines.push(`Gtd-Format: ${HISTORY_FORMAT}`)
|
|
211
|
+
const subject = formatSubject(spec.actor, spec.to, spec.from)
|
|
212
|
+
return `${subject}\n\n${lines.join("\n")}`
|
|
213
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { diffTrees, type TreeView } from "./Tree.js"
|
|
3
|
+
|
|
4
|
+
const tree = (files: Record<string, { id?: string; content: string }>): TreeView => ({
|
|
5
|
+
paths: () => Object.keys(files).sort(),
|
|
6
|
+
read: (path) => files[path]?.content,
|
|
7
|
+
id: (path) => files[path]?.id,
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
describe("diffTrees", () => {
|
|
11
|
+
it("compares blob ids where both sides have one, whatever the bytes read", () => {
|
|
12
|
+
const before = tree({ "a.txt": { id: "1", content: "one\n" } })
|
|
13
|
+
const after = tree({ "a.txt": { id: "1", content: "one\r\n" } })
|
|
14
|
+
expect(diffTrees(before, after).modified).toEqual([])
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it("falls back to contents for a path one side has no id for", () => {
|
|
18
|
+
const before = tree({
|
|
19
|
+
"a.txt": { id: "1", content: "one\n" },
|
|
20
|
+
"b.txt": { id: "2", content: "b" },
|
|
21
|
+
})
|
|
22
|
+
const after = tree({ "a.txt": { content: "one\n" }, "b.txt": { content: "changed" } })
|
|
23
|
+
expect(diffTrees(before, after).modified).toEqual(["b.txt"])
|
|
24
|
+
})
|
|
25
|
+
})
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One committed (or about-to-be-committed) tree, read synchronously: flow code
|
|
3
|
+
* calls helpers between two awaits and cannot wait on IO. A git-backed view
|
|
4
|
+
* reads objects on demand; `id` lets `diffTrees` compare blob ids instead of
|
|
5
|
+
* contents, and a path whose id is `undefined` is compared by contents.
|
|
6
|
+
*/
|
|
7
|
+
export interface TreeView {
|
|
8
|
+
readonly paths: () => readonly string[]
|
|
9
|
+
readonly read: (path: string) => string | undefined
|
|
10
|
+
readonly id?: (path: string) => string | undefined
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const treeFromRecord = (files: Readonly<Record<string, string>>): TreeView => ({
|
|
14
|
+
paths: () => Object.keys(files).sort(),
|
|
15
|
+
read: (path) => (Object.hasOwn(files, path) ? files[path] : undefined),
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
export interface TreeDiff {
|
|
19
|
+
readonly added: readonly string[]
|
|
20
|
+
readonly modified: readonly string[]
|
|
21
|
+
readonly deleted: readonly string[]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const diffTrees = (before: TreeView, after: TreeView): TreeDiff => {
|
|
25
|
+
// Blob ids only compare against blob ids; otherwise fall back to contents.
|
|
26
|
+
const same = (path: string): boolean => {
|
|
27
|
+
const a = before.id?.(path)
|
|
28
|
+
const b = after.id?.(path)
|
|
29
|
+
return a !== undefined && b !== undefined ? a === b : before.read(path) === after.read(path)
|
|
30
|
+
}
|
|
31
|
+
const beforePaths = new Set(before.paths())
|
|
32
|
+
const afterPaths = new Set(after.paths())
|
|
33
|
+
const added: string[] = []
|
|
34
|
+
const modified: string[] = []
|
|
35
|
+
const deleted: string[] = []
|
|
36
|
+
for (const path of afterPaths) {
|
|
37
|
+
if (!beforePaths.has(path)) added.push(path)
|
|
38
|
+
else if (!same(path)) modified.push(path)
|
|
39
|
+
}
|
|
40
|
+
for (const path of beforePaths) if (!afterPaths.has(path)) deleted.push(path)
|
|
41
|
+
return { added: added.sort(), modified: modified.sort(), deleted: deleted.sort() }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const isEmptyDiff = (diff: TreeDiff): boolean =>
|
|
45
|
+
diff.added.length === 0 && diff.modified.length === 0 && diff.deleted.length === 0
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { replay, type EpisodeCommit, type ReachedStep, type ReplayOutcome } from "./Replay.js"
|
|
2
|
+
export { treeFromRecord, type TreeView } from "./Tree.js"
|
|
3
|
+
export {
|
|
4
|
+
formatCommitMessage,
|
|
5
|
+
formatSubject,
|
|
6
|
+
parseCommitMessage,
|
|
7
|
+
type CommitSpec,
|
|
8
|
+
type JudgeVerdict,
|
|
9
|
+
} from "./Trailers.js"
|
|
10
|
+
export { accessShapeFault, foldAccess } from "./Access.js"
|
|
11
|
+
export { formatFault } from "./Format.js"
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Test-support for `Blocks.test.ts`: `blockNodesOf` takes an already-parsed
|
|
2
|
+
// tree, but a test only has raw markdown source — re-exporting the shared
|
|
3
|
+
// parser here (rather than `Blocks.test.ts` reaching into `MarkdownTree.ts`
|
|
4
|
+
// directly) keeps the boundary rule "a test reaches a neighbour only through
|
|
5
|
+
// its own fixture or the boundary's index.ts" satisfied.
|
|
6
|
+
export { parseMarkdown } from "./MarkdownTree.js"
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { blockNodesOf, blockRunInline } from "./Blocks.js"
|
|
3
|
+
import { parseMarkdown } from "./Blocks.fixture.js"
|
|
4
|
+
|
|
5
|
+
describe("blockNodesOf — T1 blast-radius: blockTitle is byte-identical for a list item containing ~~strike~~ and a bare URL", () => {
|
|
6
|
+
it("a list item's own title keeps the literal markers, unaffected by wiring the two new GFM extensions", () => {
|
|
7
|
+
const content = ["- A ~~gone~~ item https://example.com/x", ""].join("\n")
|
|
8
|
+
const tree = parseMarkdown(content)
|
|
9
|
+
const nodes = blockNodesOf(content, tree)
|
|
10
|
+
expect(nodes[0]?.title).toBe("A ~~gone~~ item https://example.com/x")
|
|
11
|
+
expect(nodes[0]?.block?.items?.[0]?.text).toBe("A ~~gone~~ item https://example.com/x")
|
|
12
|
+
})
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
describe("blockRunInline — spec-feedback regression: a nested list item's own text is not dropped", () => {
|
|
16
|
+
it("a nested item's own run joins the flattened detailInline path, unlike a hand-picked top-level-only walk", () => {
|
|
17
|
+
const content = ["- outer", " - inner deep text", ""].join("\n")
|
|
18
|
+
const tree = parseMarkdown(content)
|
|
19
|
+
const inline = blockRunInline(content, tree.children)
|
|
20
|
+
const joined = inline.map((n) => ("value" in n ? n.value : "")).join("")
|
|
21
|
+
expect(joined).toBe("outer inner deep text")
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it("still recurses at three levels deep, in document order", () => {
|
|
25
|
+
const content = ["- a", " - b", " - c", ""].join("\n")
|
|
26
|
+
const tree = parseMarkdown(content)
|
|
27
|
+
const inline = blockRunInline(content, tree.children)
|
|
28
|
+
const joined = inline.map((n) => ("value" in n ? n.value : "")).join("")
|
|
29
|
+
expect(joined).toBe("a b c")
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it("a nested item's own text still stays OUT of its parent's structural BlockListItem.inline — only the flattened detailInline path recurses", () => {
|
|
33
|
+
const content = ["- outer", " - inner", ""].join("\n")
|
|
34
|
+
const tree = parseMarkdown(content)
|
|
35
|
+
const nodes = blockNodesOf(content, tree)
|
|
36
|
+
const topItem = nodes[0]?.block?.items?.[0]
|
|
37
|
+
expect(topItem?.inline).toEqual([{ kind: "text", value: "outer" }])
|
|
38
|
+
expect(topItem?.items).toEqual([{ text: "inner", inline: [{ kind: "text", value: "inner" }] }])
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
describe("blockNodesOf — default walk, no skip predicates", () => {
|
|
43
|
+
it("yields every top-level block, in document order, when called with no options", () => {
|
|
44
|
+
const content = [
|
|
45
|
+
"# Heading",
|
|
46
|
+
"",
|
|
47
|
+
"- Item one",
|
|
48
|
+
" - Nested item",
|
|
49
|
+
"",
|
|
50
|
+
"```",
|
|
51
|
+
"line one",
|
|
52
|
+
"line two",
|
|
53
|
+
"```",
|
|
54
|
+
"",
|
|
55
|
+
"> A blockquote.",
|
|
56
|
+
"",
|
|
57
|
+
"Paragraph one.",
|
|
58
|
+
"",
|
|
59
|
+
"Paragraph two.",
|
|
60
|
+
"",
|
|
61
|
+
].join("\n")
|
|
62
|
+
const tree = parseMarkdown(content)
|
|
63
|
+
const nodes = blockNodesOf(content, tree)
|
|
64
|
+
expect(nodes.map((n) => n.block?.kind)).toEqual([
|
|
65
|
+
"heading",
|
|
66
|
+
"list",
|
|
67
|
+
"code",
|
|
68
|
+
"blockquote",
|
|
69
|
+
"paragraph",
|
|
70
|
+
"paragraph",
|
|
71
|
+
])
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it("excludes a footnoteDefinition node unconditionally", () => {
|
|
75
|
+
const content = ["Paragraph with a marker[^fn1].", "", "[^fn1]: The note body.", ""].join("\n")
|
|
76
|
+
const tree = parseMarkdown(content)
|
|
77
|
+
const nodes = blockNodesOf(content, tree)
|
|
78
|
+
expect(nodes.map((n) => n.block?.kind)).toEqual(["paragraph"])
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it("carries a {kind:'paragraph', line} anchor for every node, at its own start line", () => {
|
|
82
|
+
const content = ["# Heading", "", "Paragraph.", ""].join("\n")
|
|
83
|
+
const tree = parseMarkdown(content)
|
|
84
|
+
const nodes = blockNodesOf(content, tree)
|
|
85
|
+
expect(nodes.map((n) => n.anchor)).toEqual([
|
|
86
|
+
{ kind: "paragraph", line: 0 },
|
|
87
|
+
{ kind: "paragraph", line: 2 },
|
|
88
|
+
])
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it("surfaces a footnote marker anchored at a block's own start line as that block's own note", () => {
|
|
92
|
+
const content = ["# Heading[^fn1]", "", "[^fn1]: A note on the heading.", ""].join("\n")
|
|
93
|
+
const tree = parseMarkdown(content)
|
|
94
|
+
const nodes = blockNodesOf(content, tree)
|
|
95
|
+
expect(nodes[0]?.note).toBe("A note on the heading.")
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it("strips the '> ' continuation marker from a soft-broken blockquote's title and block.text", () => {
|
|
99
|
+
const content = ["> quoted line", "> more", ""].join("\n")
|
|
100
|
+
const tree = parseMarkdown(content)
|
|
101
|
+
const nodes = blockNodesOf(content, tree)
|
|
102
|
+
expect(nodes[0]?.title).not.toContain(">")
|
|
103
|
+
expect(nodes[0]?.block).toMatchObject({ kind: "blockquote", text: "quoted line more" })
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it("keeps a two-paragraph blockquote's title unchanged (blank '>' line between paragraphs)", () => {
|
|
107
|
+
const content = ["> First line.", ">", "> Second para.", ""].join("\n")
|
|
108
|
+
const tree = parseMarkdown(content)
|
|
109
|
+
const nodes = blockNodesOf(content, tree)
|
|
110
|
+
expect(nodes[0]?.title).toBe("First line. Second para.")
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it("keeps a literal '>' typed mid-prose inside a blockquote", () => {
|
|
114
|
+
const content = ["> a > b", ""].join("\n")
|
|
115
|
+
const tree = parseMarkdown(content)
|
|
116
|
+
const nodes = blockNodesOf(content, tree)
|
|
117
|
+
expect(nodes[0]?.title).toBe("a > b")
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it("keeps an inline link's [label](url) syntax intact across a multi-line blockquote", () => {
|
|
121
|
+
const content = ["> see [label](url)", "> more text", ""].join("\n")
|
|
122
|
+
const tree = parseMarkdown(content)
|
|
123
|
+
const nodes = blockNodesOf(content, tree)
|
|
124
|
+
expect(nodes[0]?.title).toBe("see [label](url) more text")
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it("keeps a line-leading '>' inside a list item's own fenced code block — the blockquote strip is scoped to blockquotes only", () => {
|
|
128
|
+
const content = ["- item", "", " ```", " a", " > b", " ```", ""].join("\n")
|
|
129
|
+
const tree = parseMarkdown(content)
|
|
130
|
+
const nodes = blockNodesOf(content, tree)
|
|
131
|
+
expect(nodes[0]?.block).toMatchObject({ kind: "list", items: [{ text: "item ``` a > b ```" }] })
|
|
132
|
+
})
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
describe("blockNodesOf — caller-supplied skip predicates", () => {
|
|
136
|
+
it("skipNode excludes a top-level node the caller identifies by content", () => {
|
|
137
|
+
const content = ["# Skip me", "", "Paragraph.", ""].join("\n")
|
|
138
|
+
const tree = parseMarkdown(content)
|
|
139
|
+
const nodes = blockNodesOf(content, tree, {
|
|
140
|
+
skipNode: (_content, node) => node.type === "heading",
|
|
141
|
+
})
|
|
142
|
+
expect(nodes.map((n) => n.block?.kind)).toEqual(["paragraph"])
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it("skipLine excludes a top-level node by its own start line", () => {
|
|
146
|
+
const content = ["# Heading", "", "Paragraph.", ""].join("\n")
|
|
147
|
+
const tree = parseMarkdown(content)
|
|
148
|
+
const nodes = blockNodesOf(content, tree, { skipLine: (line) => line === 0 })
|
|
149
|
+
expect(nodes.map((n) => n.block?.kind)).toEqual(["paragraph"])
|
|
150
|
+
})
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
describe("blockNodesOf — block.text", () => {
|
|
154
|
+
it("omits block.text entirely for heading/list/paragraph", () => {
|
|
155
|
+
const content = ["# A heading", "", "Plain paragraph.", ""].join("\n")
|
|
156
|
+
const tree = parseMarkdown(content)
|
|
157
|
+
const nodes = blockNodesOf(content, tree)
|
|
158
|
+
expect(nodes.map((n) => n.block?.text)).toEqual([undefined, undefined])
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it("omits block entirely for a thematicBreak — unchanged from qa/review's own behaviour", () => {
|
|
162
|
+
const content = ["Above.", "", "---", "", "Below.", ""].join("\n")
|
|
163
|
+
const tree = parseMarkdown(content)
|
|
164
|
+
const nodes = blockNodesOf(content, tree)
|
|
165
|
+
expect(nodes.map((n) => n.block?.kind)).toEqual(["paragraph", undefined, "paragraph"])
|
|
166
|
+
})
|
|
167
|
+
})
|