@pmelab/gtd 24.0.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 +31 -5
- 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 +43339 -55575
- 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 +8 -6
- package/package.json +69 -8
- 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/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/workflows/feature/feature.test.ts +27 -33
- package/src/workflows/feature/index.ts +10 -0
- package/src/workflows/fix/fix.feature +80 -0
- package/src/workflows/fix/fix.test.ts +58 -78
- package/src/workflows/fix/index.ts +6 -0
- package/src/workflows/index.ts +2 -10
- package/src/workflows/review/index.ts +6 -0
- package/src/workflows/review/review.test.ts +85 -29
- package/src/workflows/review/review.ts +3 -1
- package/src/workflows/shared/config.fixture.ts +12 -0
- package/src/workflows/shared/reserved.ts +11 -0
- package/src/workflows/shared/workflows.steps.ts +7 -0
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { freeFormFormat } from "./freeform.js"
|
|
3
|
+
import { BUILT_IN_MODE_NAMES, steeringFormatFor } from "./index.js"
|
|
4
|
+
|
|
5
|
+
/** Applies edits back-to-front — a local copy of the file's own splice pattern, mirroring `qa.test.ts`/`review.test.ts`'s identical helper. */
|
|
6
|
+
const applyEdits = (
|
|
7
|
+
content: string,
|
|
8
|
+
edits: readonly { range: unknown; newText: string }[],
|
|
9
|
+
): string => {
|
|
10
|
+
const lines = content.split(/\r?\n/)
|
|
11
|
+
const toOffset = (position: { line: number; character: number }): number => {
|
|
12
|
+
let offset = 0
|
|
13
|
+
for (let i = 0; i < position.line; i += 1) offset += (lines[i]?.length ?? 0) + 1
|
|
14
|
+
return offset + position.character
|
|
15
|
+
}
|
|
16
|
+
const sorted = [...edits].sort((a, b) => {
|
|
17
|
+
const ra = a.range as { start: { line: number; character: number } }
|
|
18
|
+
const rb = b.range as { start: { line: number; character: number } }
|
|
19
|
+
return toOffset(rb.start) - toOffset(ra.start)
|
|
20
|
+
})
|
|
21
|
+
let result = content
|
|
22
|
+
for (const edit of sorted) {
|
|
23
|
+
const range = edit.range as {
|
|
24
|
+
start: { line: number; character: number }
|
|
25
|
+
end: { line: number; character: number }
|
|
26
|
+
}
|
|
27
|
+
const start = toOffset(range.start)
|
|
28
|
+
const end = toOffset(range.end)
|
|
29
|
+
result = result.slice(0, start) + edit.newText + result.slice(end)
|
|
30
|
+
}
|
|
31
|
+
return result
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Applies edits with the SAME offset convention `applySteeringEdits`
|
|
36
|
+
* (`src/ui/Write.ts`) uses in production: lines split on a bare `"\n"`, so a
|
|
37
|
+
* CRLF line keeps its `\r` as part of the line's own length. `applyEdits`
|
|
38
|
+
* above (this file's pre-existing helper) instead splits on `/\r?\n/`, which
|
|
39
|
+
* strips `\r` and undercounts offsets in a CRLF document — fine for the
|
|
40
|
+
* LF-only fixtures every other test in this file uses, but it would silently
|
|
41
|
+
* corrupt the byte-exact CRLF assertions below. Not a normalizing helper:
|
|
42
|
+
* mismatched newText/range still round-trips to the WRONG bytes, exactly as
|
|
43
|
+
* production would.
|
|
44
|
+
*/
|
|
45
|
+
const applyEditsExact = (
|
|
46
|
+
content: string,
|
|
47
|
+
edits: readonly { range: unknown; newText: string }[],
|
|
48
|
+
): string => {
|
|
49
|
+
const lines = content.split("\n")
|
|
50
|
+
const toOffset = (position: { line: number; character: number }): number => {
|
|
51
|
+
let offset = 0
|
|
52
|
+
for (let i = 0; i < position.line; i += 1) offset += (lines[i]?.length ?? 0) + 1
|
|
53
|
+
return offset + position.character
|
|
54
|
+
}
|
|
55
|
+
const sorted = [...edits].sort((a, b) => {
|
|
56
|
+
const ra = a.range as { start: { line: number; character: number } }
|
|
57
|
+
const rb = b.range as { start: { line: number; character: number } }
|
|
58
|
+
return toOffset(rb.start) - toOffset(ra.start)
|
|
59
|
+
})
|
|
60
|
+
let result = content
|
|
61
|
+
for (const edit of sorted) {
|
|
62
|
+
const range = edit.range as {
|
|
63
|
+
start: { line: number; character: number }
|
|
64
|
+
end: { line: number; character: number }
|
|
65
|
+
}
|
|
66
|
+
const start = toOffset(range.start)
|
|
67
|
+
const end = toOffset(range.end)
|
|
68
|
+
result = result.slice(0, start) + edit.newText + result.slice(end)
|
|
69
|
+
}
|
|
70
|
+
return result
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
describe("freeFormFormat — registry", () => {
|
|
74
|
+
it("is not registered: steeringFormatFor('freeform') returns undefined", () => {
|
|
75
|
+
expect(steeringFormatFor("freeform")).toBeUndefined()
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it("is not among the built-in mode names", () => {
|
|
79
|
+
expect(BUILT_IN_MODE_NAMES).not.toContain("freeform")
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
describe("freeFormFormat.sample", () => {
|
|
84
|
+
it("validates clean", () => {
|
|
85
|
+
expect(freeFormFormat.validate(freeFormFormat.sample)).toEqual([])
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
describe("freeFormFormat.validate/outline/actions", () => {
|
|
90
|
+
it("validate always returns []", () => {
|
|
91
|
+
expect(freeFormFormat.validate("# Anything\n\nGoes.\n")).toEqual([])
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it("outline is [] without threads", () => {
|
|
95
|
+
expect(freeFormFormat.outline("# Anything\n\nGoes.\n")).toEqual([])
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it("actions are [] away from a thread", () => {
|
|
99
|
+
expect(
|
|
100
|
+
freeFormFormat.actions("Some prose.\n", {
|
|
101
|
+
start: { line: 0, character: 0 },
|
|
102
|
+
end: { line: 0, character: 0 },
|
|
103
|
+
}),
|
|
104
|
+
).toEqual([])
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it("has no pointerAt or documentLinks", () => {
|
|
108
|
+
expect(freeFormFormat.pointerAt).toBeUndefined()
|
|
109
|
+
expect(freeFormFormat.documentLinks).toBeUndefined()
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
describe("freeFormFormat.view", () => {
|
|
114
|
+
it("returns every top-level block, in document order, each with a {kind:'paragraph', line} anchor", () => {
|
|
115
|
+
const content = ["# Heading", "", "- one", "- two", "", "Paragraph.", ""].join("\n")
|
|
116
|
+
const view = freeFormFormat.view(content)
|
|
117
|
+
expect(view.nodes.map((n) => n.anchor)).toEqual([
|
|
118
|
+
{ kind: "paragraph", line: 0 },
|
|
119
|
+
{ kind: "paragraph", line: 2 },
|
|
120
|
+
{ kind: "paragraph", line: 5 },
|
|
121
|
+
])
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it("a fenced ts code block's own text is the fence-stripped body, byte-identical to qa/review's own view", () => {
|
|
125
|
+
const content = ["```ts", "const x = 1", "```", ""].join("\n")
|
|
126
|
+
const view = freeFormFormat.view(content)
|
|
127
|
+
expect(view.nodes.map((n) => n.block?.text)).toEqual(["const x = 1"])
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it("a two-line '>' quote's own text is marker-free, byte-identical to qa/review's own view", () => {
|
|
131
|
+
const content = ["> quoted line", "> more", ""].join("\n")
|
|
132
|
+
const view = freeFormFormat.view(content)
|
|
133
|
+
expect(view.nodes.map((n) => n.block?.text)).toEqual(["quoted line more"])
|
|
134
|
+
})
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
describe("freeFormFormat.apply — a block-start anchor now refuses", () => {
|
|
138
|
+
it("refuses anchor-not-found for a line matching a top-level block's own start line, on a non-empty document", () => {
|
|
139
|
+
const content = ["# Heading", "", "Paragraph one.", "", "Paragraph two.", ""].join("\n")
|
|
140
|
+
const result = freeFormFormat.apply(content, { kind: "paragraph", line: 2 }, { text: "x" })
|
|
141
|
+
expect(result).toEqual({ ok: false, reason: "anchor-not-found" })
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it("refuses a heading's own start line the same way", () => {
|
|
145
|
+
const content = ["# Old heading", "", "Body.", ""].join("\n")
|
|
146
|
+
const result = freeFormFormat.apply(content, { kind: "paragraph", line: 0 }, { text: "# New" })
|
|
147
|
+
expect(result).toEqual({ ok: false, reason: "anchor-not-found" })
|
|
148
|
+
})
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
describe("freeFormFormat.apply — appending", () => {
|
|
152
|
+
it("a line at the document's last line appends text at the end, preceded by a blank line", () => {
|
|
153
|
+
const content = ["Paragraph one.", ""].join("\n")
|
|
154
|
+
const lastLine = content.split(/\r?\n/).length - 1
|
|
155
|
+
const result = freeFormFormat.apply(
|
|
156
|
+
content,
|
|
157
|
+
{ kind: "paragraph", line: lastLine },
|
|
158
|
+
{ text: "New content." },
|
|
159
|
+
)
|
|
160
|
+
expect(result.ok).toBe(true)
|
|
161
|
+
const applied = result.ok ? applyEdits(content, result.edits) : ""
|
|
162
|
+
expect(applied).toBe(["Paragraph one.", "", "New content.", ""].join("\n"))
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it("a line well past the document's end also appends", () => {
|
|
166
|
+
const content = ["Paragraph one.", ""].join("\n")
|
|
167
|
+
const result = freeFormFormat.apply(
|
|
168
|
+
content,
|
|
169
|
+
{ kind: "paragraph", line: 9999 },
|
|
170
|
+
{ text: "New content." },
|
|
171
|
+
)
|
|
172
|
+
expect(result.ok).toBe(true)
|
|
173
|
+
const applied = result.ok ? applyEdits(content, result.edits) : ""
|
|
174
|
+
expect(applied).toBe(["Paragraph one.", "", "New content.", ""].join("\n"))
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it("appending to an empty document produces just the new text", () => {
|
|
178
|
+
const result = freeFormFormat.apply("", { kind: "paragraph", line: 0 }, { text: "First line." })
|
|
179
|
+
expect(result.ok).toBe(true)
|
|
180
|
+
const applied = result.ok ? applyEdits("", result.edits) : ""
|
|
181
|
+
expect(applied).toBe("First line.\n")
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it("the empty-document save path: Number.MAX_SAFE_INTEGER against '' reaches the append edit, reading no block's raw text", () => {
|
|
185
|
+
const result = freeFormFormat.apply(
|
|
186
|
+
"",
|
|
187
|
+
{ kind: "paragraph", line: Number.MAX_SAFE_INTEGER },
|
|
188
|
+
{ text: "x" },
|
|
189
|
+
)
|
|
190
|
+
expect(result.ok).toBe(true)
|
|
191
|
+
const applied = result.ok ? applyEdits("", result.edits) : ""
|
|
192
|
+
expect(applied).toBe("x\n")
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it("an empty append (opts.text omitted, an append row submitted with nothing typed) leaves the document unchanged, not a bare trailing blank line", () => {
|
|
196
|
+
const content = ["Paragraph one.", ""].join("\n")
|
|
197
|
+
const lastLine = content.split(/\r?\n/).length - 1
|
|
198
|
+
const result = freeFormFormat.apply(content, { kind: "paragraph", line: lastLine }, {})
|
|
199
|
+
expect(result.ok).toBe(true)
|
|
200
|
+
const applied = result.ok ? applyEdits(content, result.edits) : ""
|
|
201
|
+
expect(applied).toBe(["Paragraph one.", ""].join("\n"))
|
|
202
|
+
})
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
describe("freeFormFormat.apply — no block at that line", () => {
|
|
206
|
+
it("refuses anchor-not-found for a line resolving to no block, not past the end", () => {
|
|
207
|
+
const content = ["Paragraph one.", "", "Paragraph two.", ""].join("\n")
|
|
208
|
+
const result = freeFormFormat.apply(content, { kind: "paragraph", line: 1 }, { text: "x" })
|
|
209
|
+
expect(result).toEqual({ ok: false, reason: "anchor-not-found" })
|
|
210
|
+
})
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
describe("freeFormFormat.apply — wrong anchor kind", () => {
|
|
214
|
+
it("refuses a non-paragraph anchor", () => {
|
|
215
|
+
const content = "Paragraph.\n"
|
|
216
|
+
const result = freeFormFormat.apply(content, { kind: "chunk", index: 0 }, { text: "x" })
|
|
217
|
+
expect(result).toEqual({ ok: false, reason: "anchor-not-found" })
|
|
218
|
+
})
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
describe("freeFormFormat.annotate", () => {
|
|
222
|
+
it("delegates to Footnotes.ts#footnoteAttachEdits: attaches a marker and a definition", () => {
|
|
223
|
+
const content = ["Paragraph one.", ""].join("\n")
|
|
224
|
+
const result = freeFormFormat.annotate(content, { kind: "paragraph", line: 0 }, "A note.")
|
|
225
|
+
expect(result.ok).toBe(true)
|
|
226
|
+
const applied = result.ok ? applyEdits(content, result.edits) : ""
|
|
227
|
+
expect(applied).toContain("Paragraph one.[^")
|
|
228
|
+
expect(applied).toContain("A note.")
|
|
229
|
+
expect(freeFormFormat.validate(applied)).toEqual([])
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it("creates a thread, appends to an open one, replaces on a waiting one — each validates clean", () => {
|
|
233
|
+
const anchor = { kind: "paragraph" as const, line: 0 }
|
|
234
|
+
const attach = (content: string, text: string): string => {
|
|
235
|
+
const r = freeFormFormat.annotate(content, anchor, text)
|
|
236
|
+
if (!r.ok) throw new Error("refused")
|
|
237
|
+
return applyEdits(content, r.edits)
|
|
238
|
+
}
|
|
239
|
+
const created = attach("Paragraph one.\n", "first")
|
|
240
|
+
expect(created).toMatch(/\[\^na\w+\]:\n - H: first\n/)
|
|
241
|
+
expect(freeFormFormat.validate(created)).toEqual([])
|
|
242
|
+
const replaced = attach(created, "second")
|
|
243
|
+
expect(replaced).toContain("- H: second")
|
|
244
|
+
expect(replaced).not.toContain("first")
|
|
245
|
+
const open = replaced.replace("- H: second", "- H: second\n - A: why?")
|
|
246
|
+
const replied = attach(open, "because")
|
|
247
|
+
expect(replied).toContain(" - A: why?\n - H: because")
|
|
248
|
+
expect(freeFormFormat.validate(replied)).toEqual([])
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
it("refuses a non-paragraph anchor", () => {
|
|
252
|
+
const result = freeFormFormat.annotate("x\n", { kind: "hunk", chunkIndex: 0, index: 0 }, "note")
|
|
253
|
+
expect(result).toEqual({ ok: false, reason: "anchor-not-found" })
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
it("refuses when the line isn't inside any real block", () => {
|
|
257
|
+
const content = ["Paragraph one.", "", "Paragraph two.", ""].join("\n")
|
|
258
|
+
const result = freeFormFormat.annotate(content, { kind: "paragraph", line: 1 }, "note")
|
|
259
|
+
expect(result).toEqual({ ok: false, reason: "anchor-not-found" })
|
|
260
|
+
})
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
describe("freeFormFormat.apply — CRLF documents preserve untouched bytes", () => {
|
|
264
|
+
it("a block-start anchor refuses on a CRLF document exactly as it does on LF", () => {
|
|
265
|
+
const content = ["# Heading", "", "Paragraph one.", "", "Paragraph two.", ""].join("\r\n")
|
|
266
|
+
const result = freeFormFormat.apply(
|
|
267
|
+
content,
|
|
268
|
+
{ kind: "paragraph", line: 2 },
|
|
269
|
+
{ text: "Edited." },
|
|
270
|
+
)
|
|
271
|
+
expect(result).toEqual({ ok: false, reason: "anchor-not-found" })
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
it("appending to a CRLF document uses CRLF for the new bytes too", () => {
|
|
275
|
+
const content = ["Paragraph one.", ""].join("\r\n")
|
|
276
|
+
const lastLine = content.split(/\r?\n/).length - 1
|
|
277
|
+
const result = freeFormFormat.apply(
|
|
278
|
+
content,
|
|
279
|
+
{ kind: "paragraph", line: lastLine },
|
|
280
|
+
{ text: "New content." },
|
|
281
|
+
)
|
|
282
|
+
expect(result.ok).toBe(true)
|
|
283
|
+
const applied = result.ok ? applyEditsExact(content, result.edits) : ""
|
|
284
|
+
expect(applied).toBe(["Paragraph one.", "", "New content.", ""].join("\r\n"))
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
it("pasting LF text into a CRLF document via append introduces no mixed endings", () => {
|
|
288
|
+
const content = ["# Heading", "", "Paragraph one.", ""].join("\r\n")
|
|
289
|
+
const lastLine = content.split(/\r?\n/).length - 1
|
|
290
|
+
const result = freeFormFormat.apply(
|
|
291
|
+
content,
|
|
292
|
+
{ kind: "paragraph", line: lastLine },
|
|
293
|
+
{ text: "Line one.\nLine two." },
|
|
294
|
+
)
|
|
295
|
+
expect(result.ok).toBe(true)
|
|
296
|
+
const applied = result.ok ? applyEditsExact(content, result.edits) : ""
|
|
297
|
+
expect(applied).toBe(
|
|
298
|
+
["# Heading", "", "Paragraph one.", "", "Line one.", "Line two.", ""].join("\r\n"),
|
|
299
|
+
)
|
|
300
|
+
expect(applied.replace(/\r\n/g, "")).not.toContain("\n")
|
|
301
|
+
})
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
describe("freeFormFormat — thread outline", () => {
|
|
305
|
+
it("free-form lists threads at top level", () => {
|
|
306
|
+
const doc = "Text [^a].\n\n[^a]:\n - H: hi\n - A: yo\n"
|
|
307
|
+
expect(freeFormFormat.outline(doc).map((n) => [n.name, n.detail])).toEqual([
|
|
308
|
+
["[^a]", "waiting on you"],
|
|
309
|
+
])
|
|
310
|
+
})
|
|
311
|
+
})
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { blockNodesOf } from "./Blocks.js"
|
|
2
|
+
import { eolOf } from "./Eol.js"
|
|
3
|
+
import {
|
|
4
|
+
footnoteAttachEdits,
|
|
5
|
+
openThreadsOf,
|
|
6
|
+
threadFindings,
|
|
7
|
+
threadOutlineNodes,
|
|
8
|
+
threadReplyActions,
|
|
9
|
+
type FootnoteAnchor,
|
|
10
|
+
} from "./Footnotes.js"
|
|
11
|
+
import { blockNodeAt, parseMarkdown, toLspPosition } from "./MarkdownTree.js"
|
|
12
|
+
import type {
|
|
13
|
+
SteeringAnnotateResult,
|
|
14
|
+
SteeringEdit,
|
|
15
|
+
SteeringFormat,
|
|
16
|
+
SteeringView,
|
|
17
|
+
} from "./SteeringFormat.js"
|
|
18
|
+
|
|
19
|
+
const FREE_FORM_SAMPLE = `Sample plan. Add a thing.
|
|
20
|
+
|
|
21
|
+
## Notes
|
|
22
|
+
|
|
23
|
+
Nothing here is validated — free-form has no structure to conform to.
|
|
24
|
+
`
|
|
25
|
+
|
|
26
|
+
/** Resolves a `paragraph` anchor: attaches at the end of `line`'s own text, the definition landing after that line's containing top-level block node. `undefined` when `line` isn't inside a real block — mirrors `qa.ts`/`review.ts`'s identical resolver. */
|
|
27
|
+
const resolveParagraphAnchor = (
|
|
28
|
+
tree: ReturnType<typeof parseMarkdown>,
|
|
29
|
+
lines: readonly string[],
|
|
30
|
+
line: number,
|
|
31
|
+
): FootnoteAnchor | undefined => {
|
|
32
|
+
const block = blockNodeAt(tree, line)
|
|
33
|
+
if (!block?.position) return undefined
|
|
34
|
+
return {
|
|
35
|
+
line,
|
|
36
|
+
endCharacter: (lines[line] ?? "").length,
|
|
37
|
+
blockEndLine: toLspPosition(block.position.end).line,
|
|
38
|
+
key: `paragraph:${line}`,
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Free-form's `annotate`: only a `paragraph` anchor resolves here (it's the only kind `view` ever reports) — delegates the two-edit mechanics to `Footnotes.ts#footnoteAttachEdits`, exactly as both built-ins do. */
|
|
43
|
+
const freeFormAnnotate: SteeringFormat["annotate"] = (
|
|
44
|
+
content,
|
|
45
|
+
anchor,
|
|
46
|
+
text,
|
|
47
|
+
): SteeringAnnotateResult => {
|
|
48
|
+
if (anchor.kind !== "paragraph") return { ok: false, reason: "anchor-not-found" }
|
|
49
|
+
const tree = parseMarkdown(content)
|
|
50
|
+
const lines = content.split(/\r?\n/)
|
|
51
|
+
const resolved = resolveParagraphAnchor(tree, lines, anchor.line)
|
|
52
|
+
if (!resolved) return { ok: false, reason: "anchor-not-found" }
|
|
53
|
+
const result = footnoteAttachEdits(content, resolved, text)
|
|
54
|
+
if (!result.ok) return result
|
|
55
|
+
return { ok: true, edits: result.edits }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A single edit spanning the WHOLE document, replacing it with `newText` —
|
|
60
|
+
* free-form's `apply` reasons about the resulting document as a plain string
|
|
61
|
+
* (splice/join on lines) rather than surgical offset math, since there is no
|
|
62
|
+
* structure here to preserve beyond the blocks themselves. Its end position
|
|
63
|
+
* is computed from a plain `"\n"` split — matching `applySteeringEdits`'s own
|
|
64
|
+
* offset convention (a CRLF line keeps its `\r` as part of the line's own
|
|
65
|
+
* length) rather than `/\r?\n/`, which would strip it and undercount.
|
|
66
|
+
*/
|
|
67
|
+
const wholeDocumentEdit = (content: string, newText: string): SteeringEdit => {
|
|
68
|
+
const lines = content.split("\n")
|
|
69
|
+
return {
|
|
70
|
+
range: {
|
|
71
|
+
start: { line: 0, character: 0 },
|
|
72
|
+
end: { line: lines.length - 1, character: (lines[lines.length - 1] ?? "").length },
|
|
73
|
+
},
|
|
74
|
+
newText,
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The append edit: `text` lands at the very end of the document, preceded by
|
|
80
|
+
* exactly one blank line — trailing whitespace on the existing document is
|
|
81
|
+
* trimmed first so this is idempotent regardless of how many blank lines
|
|
82
|
+
* already trail it. An empty `text` (an append row submitted with nothing
|
|
83
|
+
* typed) appends nothing rather than a bare trailing blank line.
|
|
84
|
+
*/
|
|
85
|
+
const appendEdit = (content: string, text: string): SteeringEdit => {
|
|
86
|
+
const eol = eolOf(content)
|
|
87
|
+
const trimmed = content.replace(/\s+$/, "")
|
|
88
|
+
const normalizedText = text.split(/\r?\n/).join(eol)
|
|
89
|
+
if (text.length === 0)
|
|
90
|
+
return wholeDocumentEdit(content, trimmed.length > 0 ? `${trimmed}${eol}` : "")
|
|
91
|
+
const newText =
|
|
92
|
+
trimmed.length > 0 ? `${trimmed}${eol}${eol}${normalizedText}${eol}` : `${normalizedText}${eol}`
|
|
93
|
+
return wholeDocumentEdit(content, newText)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Free-form's `apply`: only a `paragraph` anchor resolves (the only kind
|
|
98
|
+
* `view` ever reports), and it only ever APPENDS, at or past the document's
|
|
99
|
+
* last line. An `anchor.line` landing on a real top-level block's own start
|
|
100
|
+
* line — or anywhere else inside the document short of its last line —
|
|
101
|
+
* refuses `anchor-not-found`: per-block replace/delete were dropped with the
|
|
102
|
+
* phone's own per-block editing UI (this package), leaving append as the
|
|
103
|
+
* only write this format still performs.
|
|
104
|
+
*/
|
|
105
|
+
const freeFormApply: SteeringFormat["apply"] = (content, anchor, opts): SteeringAnnotateResult => {
|
|
106
|
+
if (anchor.kind !== "paragraph") return { ok: false, reason: "anchor-not-found" }
|
|
107
|
+
const text = opts.text ?? ""
|
|
108
|
+
const tree = parseMarkdown(content)
|
|
109
|
+
const lines = content.split(/\r?\n/)
|
|
110
|
+
const lastLineIndex = lines.length - 1
|
|
111
|
+
const isBlockStart = tree.children.some(
|
|
112
|
+
(node) =>
|
|
113
|
+
node.position !== undefined && toLspPosition(node.position.start).line === anchor.line,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
if (isBlockStart) return { ok: false, reason: "anchor-not-found" }
|
|
117
|
+
if (anchor.line < lastLineIndex) return { ok: false, reason: "anchor-not-found" }
|
|
118
|
+
return { ok: true, edits: [appendEdit(content, text)] }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Free-form's `view`: every top-level block, in document order — no format-specific structure (no questions, no chunks) to project beyond the shared block walk. */
|
|
122
|
+
const freeFormView = (content: string): SteeringView => {
|
|
123
|
+
const tree = parseMarkdown(content)
|
|
124
|
+
return { nodes: blockNodesOf(content, tree) }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The free-form `SteeringFormat`: no structure to validate, outline, or
|
|
129
|
+
* offer code actions over — a mode-less steering file has no schema to
|
|
130
|
+
* conform to, so `validate`/`outline`/`actions` are all constant `[]`, and
|
|
131
|
+
* there is nothing to jump to (`pointerAt`/`documentLinks` stay absent).
|
|
132
|
+
* Exported but deliberately NOT registered in `index.ts#REGISTRY` — this
|
|
133
|
+
* package's later tasks wire it in as the FALLBACK for an absent or
|
|
134
|
+
* unregistered `mode`, never a mode name of its own.
|
|
135
|
+
*/
|
|
136
|
+
export const freeFormFormat: SteeringFormat = {
|
|
137
|
+
sample: FREE_FORM_SAMPLE,
|
|
138
|
+
validate: threadFindings,
|
|
139
|
+
openThreads: openThreadsOf,
|
|
140
|
+
outline: (content) => threadOutlineNodes(content).map((t) => t.node),
|
|
141
|
+
actions: (content, range) => threadReplyActions(content, range.start),
|
|
142
|
+
view: freeFormView,
|
|
143
|
+
annotate: freeFormAnnotate,
|
|
144
|
+
apply: freeFormApply,
|
|
145
|
+
// Nothing here is a read-progress tick: free-form has no structure whose
|
|
146
|
+
// checkboxes mean anything to this engine.
|
|
147
|
+
clearTicks: (content) => content,
|
|
148
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { BUILT_IN_MODE_NAMES, steeringFormatFor, unansweredQuestions, viewOf } from "./index.js"
|
|
3
|
+
|
|
4
|
+
const qa = steeringFormatFor("qa")!
|
|
5
|
+
const review = steeringFormatFor("review")!
|
|
6
|
+
|
|
7
|
+
describe("steeringFormatFor / BUILT_IN_MODE_NAMES", () => {
|
|
8
|
+
it("resolves both built-in modes by name", () => {
|
|
9
|
+
expect(steeringFormatFor("qa")).toBeDefined()
|
|
10
|
+
expect(steeringFormatFor("review")).toBeDefined()
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it("is undefined for a name that isn't a built-in mode", () => {
|
|
14
|
+
expect(steeringFormatFor("bogus")).toBeUndefined()
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it("lists both built-in mode names, in registry order", () => {
|
|
18
|
+
expect(BUILT_IN_MODE_NAMES).toEqual(["qa", "review"])
|
|
19
|
+
})
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
describe("dispatch over a resolved format — cross-format safety", () => {
|
|
23
|
+
it("each registered format validates with its own rule, not a shared or wrong one", () => {
|
|
24
|
+
expect(qa.validate("")).toEqual([])
|
|
25
|
+
expect(review.validate("").length).toBeGreaterThan(0)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it("unansweredQuestions is qa-only — review always answers []", () => {
|
|
29
|
+
expect(unansweredQuestions(review, review.sample)).toEqual([])
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it("clearTicks is format-parameterised — running review's clearTicks over qa-shaped content is a no-op", () => {
|
|
33
|
+
const qaShapedContent = [
|
|
34
|
+
"Plan.",
|
|
35
|
+
"",
|
|
36
|
+
"## Open Questions",
|
|
37
|
+
"",
|
|
38
|
+
"### Q?",
|
|
39
|
+
"",
|
|
40
|
+
"- [x] Option A",
|
|
41
|
+
"- [ ] Option B",
|
|
42
|
+
"",
|
|
43
|
+
].join("\n")
|
|
44
|
+
expect(qa.clearTicks(qaShapedContent)).toBe(qaShapedContent)
|
|
45
|
+
expect(review.clearTicks(qaShapedContent)).toBe(qaShapedContent)
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
describe("viewOf", () => {
|
|
50
|
+
it("qa's view has no documentLinks (qa declares none) but does have an outline", () => {
|
|
51
|
+
const view = viewOf(qa, qa.sample)
|
|
52
|
+
expect(view.documentLinks).toEqual([])
|
|
53
|
+
expect(view.outline.length).toBeGreaterThan(0)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it("review's documentLinks carry the hunk pointer's own range, matching its outline's hunk-adjacent chunk range", () => {
|
|
57
|
+
const view = viewOf(review, review.sample)
|
|
58
|
+
expect(view.documentLinks.length).toBe(1)
|
|
59
|
+
expect(view.documentLinks[0]!.path).toBe("./sample.ts")
|
|
60
|
+
expect(view.documentLinks[0]!.line).toBe(0)
|
|
61
|
+
})
|
|
62
|
+
})
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
SteeringAction,
|
|
3
|
+
SteeringFormat,
|
|
4
|
+
SteeringLink,
|
|
5
|
+
SteeringOutlineNode,
|
|
6
|
+
SteeringPointer,
|
|
7
|
+
} from "./SteeringFormat.js"
|
|
8
|
+
import { qaDescriptor } from "./qa.js"
|
|
9
|
+
import { reviewDescriptor } from "./review.js"
|
|
10
|
+
export { reviewNotes, reviewRisks, type ReviewNote } from "./review.js"
|
|
11
|
+
export { applySteeringEdits } from "./Edits.js"
|
|
12
|
+
|
|
13
|
+
export type { InlineNode } from "./Inline.js"
|
|
14
|
+
|
|
15
|
+
// The free-form format: the mode-less/unregistered-mode FALLBACK —
|
|
16
|
+
// deliberately not a `REGISTRY` entry of its own, since it is never a mode a
|
|
17
|
+
// `mode:` key names.
|
|
18
|
+
import { freeFormFormat } from "./freeform.js"
|
|
19
|
+
export { freeFormFormat }
|
|
20
|
+
|
|
21
|
+
export type {
|
|
22
|
+
BlockListItem,
|
|
23
|
+
SteeringAction,
|
|
24
|
+
SteeringAnchor,
|
|
25
|
+
SteeringFinding,
|
|
26
|
+
SteeringFormat,
|
|
27
|
+
SteeringLink,
|
|
28
|
+
SteeringOutlineNode,
|
|
29
|
+
SteeringPointer,
|
|
30
|
+
SteeringView,
|
|
31
|
+
SteeringViewNode,
|
|
32
|
+
SteeringViewThread,
|
|
33
|
+
} from "./SteeringFormat.js"
|
|
34
|
+
|
|
35
|
+
/** The built-in registry: mode name → its format, in registry order. The single place `qa` and `review` are wired into names — everything else in this module dispatches over a resolved `SteeringFormat`, never a mode-name string. */
|
|
36
|
+
const REGISTRY: ReadonlyMap<string, SteeringFormat> = new Map([
|
|
37
|
+
["qa", qaDescriptor],
|
|
38
|
+
["review", reviewDescriptor],
|
|
39
|
+
])
|
|
40
|
+
|
|
41
|
+
/** Every built-in format name, in registry order. */
|
|
42
|
+
export const BUILT_IN_MODE_NAMES: readonly string[] = [...REGISTRY.keys()]
|
|
43
|
+
|
|
44
|
+
// The two `qa` primitives the phone client itself needs: `src/web/screens/
|
|
45
|
+
// Question.tsx` renders the free-text slot and derives answeredness in the
|
|
46
|
+
// browser, against the SAME constant and predicate the server validates
|
|
47
|
+
// with — a second copy there would drift silently.
|
|
48
|
+
export { FREE_TEXT_PLACEHOLDER, isAnswered } from "./qa.js"
|
|
49
|
+
|
|
50
|
+
// `ModeContradiction.ts`'s round-trip check re-parses a formatted sample's
|
|
51
|
+
// footnotes to prove the formatter moved none of them — the one consumer
|
|
52
|
+
// outside this package that needs the footnote parser itself, not a format.
|
|
53
|
+
export {
|
|
54
|
+
parseFootnotes,
|
|
55
|
+
parseThreads,
|
|
56
|
+
parseThreadsWithFindings,
|
|
57
|
+
openThreadFindings,
|
|
58
|
+
FOOTNOTE_ACTION_TITLE,
|
|
59
|
+
THREAD_REPLY_ACTION_TITLE,
|
|
60
|
+
} from "./Footnotes.js"
|
|
61
|
+
|
|
62
|
+
// Test-observability only: the memo-hit counter is the one way "one parse per
|
|
63
|
+
// document" is assertable from outside `MarkdownTree.ts` — timing is not.
|
|
64
|
+
export { getParseCount } from "./MarkdownTree.js"
|
|
65
|
+
|
|
66
|
+
// A flow's `sections()` is the one caller outside this package.
|
|
67
|
+
export { headingSectionBodies, headingSections } from "./MarkdownTree.js"
|
|
68
|
+
|
|
69
|
+
/** The built-in `SteeringFormat` registered under `mode`'s name, or `undefined` when it isn't a built-in mode at all. The one place a bare mode-name string is ever looked up — every consumer takes the RESOLVED value this returns, never a name, so "mode is required, never inferred from a file's basename" is a type, not a rule to remember. */
|
|
70
|
+
export const steeringFormatFor = (mode: string): SteeringFormat | undefined => REGISTRY.get(mode)
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The ui boundary's own resolution: `mode`'s registered format, or
|
|
74
|
+
* `freeFormFormat` when `mode` is absent or names nothing in `REGISTRY` —
|
|
75
|
+
* never `undefined`. `resolveMode` (`SteeringMode.ts`) stays on
|
|
76
|
+
* `steeringFormatFor` alone, so a workflow naming a mode nothing defines
|
|
77
|
+
* still fails to compile; this fallback is deliberately reachable only from
|
|
78
|
+
* `src/ui/View.ts` and `src/ui/Write.ts`, where a mode-less/unknown-mode
|
|
79
|
+
* document must still render and accept a paragraph note, never refuse.
|
|
80
|
+
*/
|
|
81
|
+
export const steeringFormatOrFreeForm = (mode: string | undefined): SteeringFormat =>
|
|
82
|
+
(mode !== undefined ? REGISTRY.get(mode) : undefined) ?? freeFormFormat
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* `qa`-only: every OPEN question in `content` not yet answered, as
|
|
86
|
+
* `{ question, headingLine }` pairs — enough for a refusal message ("which
|
|
87
|
+
* question", "where"). Semantically distinct from `validate`'s findings:
|
|
88
|
+
* a document can be perfectly well-formed (zero findings) while still having
|
|
89
|
+
* open questions nobody has answered, which is the whole point of the
|
|
90
|
+
* answer-completeness gate this feeds. Format-parameterised like everything
|
|
91
|
+
* else here: `review` (or any future format with no such concept) yields
|
|
92
|
+
* `[]` rather than this being a `qa`-specific function some caller has to
|
|
93
|
+
* know not to call on the wrong format.
|
|
94
|
+
*/
|
|
95
|
+
export const unansweredQuestions = (
|
|
96
|
+
format: SteeringFormat,
|
|
97
|
+
content: string,
|
|
98
|
+
): readonly { readonly question: string; readonly headingLine: number }[] =>
|
|
99
|
+
format.unansweredQuestions?.(content) ?? []
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Every positional thing an editor (the LSP, in a later pass) needs for one
|
|
103
|
+
* document, off ONE `viewOf` call rather than four separate ones re-deriving
|
|
104
|
+
* ranges each time: `outline` and `documentLinks` are already fully resolved
|
|
105
|
+
* (each node/link carries its own range — see `SteeringOutlineNode`/
|
|
106
|
+
* `SteeringLink`), while `pointerAt`/`actionsAt` stay closures over `content`
|
|
107
|
+
* because they need a cursor position/selection range the caller supplies
|
|
108
|
+
* per request. `Lsp.ts` reads its ranges straight off `outline`/`documentLinks`.
|
|
109
|
+
*/
|
|
110
|
+
export interface SteeringEditorView {
|
|
111
|
+
readonly outline: readonly SteeringOutlineNode[]
|
|
112
|
+
readonly documentLinks: readonly SteeringLink[]
|
|
113
|
+
readonly pointerAt: (position: {
|
|
114
|
+
readonly line: number
|
|
115
|
+
readonly character: number
|
|
116
|
+
}) => SteeringPointer | undefined
|
|
117
|
+
readonly actionsAt: (range: {
|
|
118
|
+
readonly start: { readonly line: number; readonly character: number }
|
|
119
|
+
readonly end: { readonly line: number; readonly character: number }
|
|
120
|
+
}) => readonly SteeringAction[]
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Builds `format`'s `SteeringEditorView` of `content` — see `SteeringEditorView`'s own doc for why this is one value, not several. Distinct from `SteeringView` (`SteeringFormat.ts`), which is the format's own node tree the phone client renders. */
|
|
124
|
+
export const viewOf = (format: SteeringFormat, content: string): SteeringEditorView => ({
|
|
125
|
+
outline: format.outline(content),
|
|
126
|
+
documentLinks: format.documentLinks ? format.documentLinks(content) : [],
|
|
127
|
+
pointerAt: (position) => format.pointerAt?.(content, position),
|
|
128
|
+
actionsAt: (range) => format.actions(content, range),
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
/** Every OPEN thread — last entry is the agent's — as `{ name, line, firstMe }` (0-based line). `[]` for a format with no thread concept. */
|
|
132
|
+
export const openThreads = (
|
|
133
|
+
format: SteeringFormat,
|
|
134
|
+
content: string,
|
|
135
|
+
): readonly { readonly name: string; readonly line: number; readonly firstMe: string }[] =>
|
|
136
|
+
format.openThreads?.(content) ?? []
|
|
137
|
+
|
|
138
|
+
// Line-comment `H:`/`A:` threads in changed code files: a flow's `codeThreads()`
|
|
139
|
+
// and `gtd check --open-threads` are the callers outside this package.
|
|
140
|
+
export { parseCodeThreads, stripCodeThreads } from "./CodeThreads.js"
|