@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,1104 @@
|
|
|
1
|
+
import type { Code, Heading, List, ListItem, Root, RootContent } from "mdast"
|
|
2
|
+
import { blockNodesOf, blockNodesOfRun, blockRunInline } from "./Blocks.js"
|
|
3
|
+
import type { Footnotes, FootnoteAnchor, FootnoteMarker } from "./Footnotes.js"
|
|
4
|
+
import { collapseImages, type InlineNode } from "./Inline.js"
|
|
5
|
+
import {
|
|
6
|
+
FOOTNOTE_ACTION_TITLE,
|
|
7
|
+
footnoteAdditionEdits,
|
|
8
|
+
footnoteAttachEdits,
|
|
9
|
+
footnotePointerAt,
|
|
10
|
+
isOnExistingFootnote,
|
|
11
|
+
openThreadsOf,
|
|
12
|
+
threadOutlineNodes,
|
|
13
|
+
threadReplyActions,
|
|
14
|
+
noteLookup,
|
|
15
|
+
parseFootnotes,
|
|
16
|
+
} from "./Footnotes.js"
|
|
17
|
+
import {
|
|
18
|
+
blockEndLine,
|
|
19
|
+
blockNodeAt,
|
|
20
|
+
footnoteLeaf,
|
|
21
|
+
headingText as sharedHeadingText,
|
|
22
|
+
lineRange,
|
|
23
|
+
parseMarkdown,
|
|
24
|
+
spanRange,
|
|
25
|
+
taskItems,
|
|
26
|
+
toLspPosition,
|
|
27
|
+
toLspPositionFromOffset,
|
|
28
|
+
type HeadingBlock,
|
|
29
|
+
} from "./MarkdownTree.js"
|
|
30
|
+
import type {
|
|
31
|
+
SteeringAnchor,
|
|
32
|
+
SteeringAnnotateResult,
|
|
33
|
+
SteeringEdit,
|
|
34
|
+
SteeringFinding,
|
|
35
|
+
SteeringFormat,
|
|
36
|
+
SteeringOutlineNode,
|
|
37
|
+
SteeringView,
|
|
38
|
+
SteeringViewNode,
|
|
39
|
+
} from "./SteeringFormat.js"
|
|
40
|
+
|
|
41
|
+
type OpenQuestionStatus = "open" | "answered"
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A marker as plain text. Local to this module because `headingText`/
|
|
45
|
+
* `optionText` extract text that is NOT a full node's tree span, which
|
|
46
|
+
* `sourceText`'s own reference exclusion does not cover.
|
|
47
|
+
*/
|
|
48
|
+
const MARKER_TEXT_RE = /\[\^([^\s\]]+)\]/g
|
|
49
|
+
|
|
50
|
+
/** Strips every `[^name]`-shaped marker out of already-extracted `text` — see `MARKER_TEXT_RE`. */
|
|
51
|
+
const stripMarkerText = (text: string): string => text.replace(MARKER_TEXT_RE, "")
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The sentinel an UNFILLED free-text option carries — the human answers by
|
|
55
|
+
* REPLACING it with their own text and ticking that line. The parser
|
|
56
|
+
* normalizes a last-option text equal to this to `""`, so a ticked-but-
|
|
57
|
+
* unfilled free-text option reads as unanswered (see `OpenQuestion.answered`).
|
|
58
|
+
*/
|
|
59
|
+
export const FREE_TEXT_PLACEHOLDER = "_your answer_"
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The canonical sample. Every detail is load-bearing: the three real options
|
|
63
|
+
* carry nested impact lists of DIFFERENT bullet counts, so the parser,
|
|
64
|
+
* outline and view tests cover more than one shape at once; both footnote
|
|
65
|
+
* bodies exceed 80 characters so the formatter round-trip actually reflows
|
|
66
|
+
* one; Option B's is `na`-prefixed (server-attached, not hand-authored) and
|
|
67
|
+
* carries a multi-word inline code span; and the whole document is already an
|
|
68
|
+
* oxfmt fixed point.
|
|
69
|
+
*/
|
|
70
|
+
const QA_SAMPLE = `Sample plan. Add a thing.
|
|
71
|
+
|
|
72
|
+
## Open Questions
|
|
73
|
+
|
|
74
|
+
### Which option?
|
|
75
|
+
|
|
76
|
+
- [ ] Option A[^fn1]
|
|
77
|
+
- Keeps today's behavior exactly as it is
|
|
78
|
+
- [ ] Option B[^na17v2bjb]
|
|
79
|
+
- Costs a migration script
|
|
80
|
+
- Buys a smaller runtime footprint
|
|
81
|
+
- [ ] Option C
|
|
82
|
+
- Splits the difference between A and B
|
|
83
|
+
- Costs the most review time
|
|
84
|
+
- Buys the easiest rollback
|
|
85
|
+
|
|
86
|
+
[^na17v2bjb]:
|
|
87
|
+
Attached via the phone UI on Option B, this note carries a
|
|
88
|
+
\`multi word code span\` and exceeds eighty characters in length so it gets
|
|
89
|
+
wrapped.
|
|
90
|
+
|
|
91
|
+
- [ ] ${FREE_TEXT_PLACEHOLDER}
|
|
92
|
+
|
|
93
|
+
[^fn1]:
|
|
94
|
+
This option keeps the current behavior exactly as it is today, which is the
|
|
95
|
+
safer default for most reviewers.
|
|
96
|
+
`
|
|
97
|
+
|
|
98
|
+
/** One checkbox option under an OPEN question: its ticked state, its text (the free-text placeholder normalized to `""`), and its source line span for editor tooling. */
|
|
99
|
+
interface QuestionOption {
|
|
100
|
+
readonly checked: boolean
|
|
101
|
+
/** Text after the `- [ ]`/`- [x]` marker, trimmed. The unfilled free-text placeholder (`FREE_TEXT_PLACEHOLDER`) normalizes to `""`. */
|
|
102
|
+
readonly text: string
|
|
103
|
+
/** `true` for the LAST option of the block — the free-text "your answer" slot (identified positionally, not by label). */
|
|
104
|
+
readonly freeText: boolean
|
|
105
|
+
readonly sourceLine: number
|
|
106
|
+
/** 0-based line index of the LAST line of this option's list item — equal to `sourceLine` unless the item's text wraps onto continuation lines. */
|
|
107
|
+
readonly endLine: number
|
|
108
|
+
/**
|
|
109
|
+
* This option's own nested blocks — a nested plain bullet list or a second
|
|
110
|
+
* paragraph under it, projected through the shared block walk
|
|
111
|
+
* (`optionBodyNodes`). `[]` when the option carries none. Never part of
|
|
112
|
+
* `text`, and never reached by the write-back span (`optionTextSpan`) —
|
|
113
|
+
* see that span's own doc comment for why it stays narrow.
|
|
114
|
+
*/
|
|
115
|
+
readonly body: readonly SteeringViewNode[]
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
interface OpenQuestion {
|
|
119
|
+
readonly question: string
|
|
120
|
+
readonly status: OpenQuestionStatus
|
|
121
|
+
/** The whole body's collapsed text (every `bodyNodes` block's own title, joined), or `""` for a question with no body — feeds the phone view's `detail`, the question card, and the LSP outline's own `detail` alike. */
|
|
122
|
+
readonly text: string
|
|
123
|
+
/** The question's own body run, projected through the shared block walk (`questionBodyNodes`) — every body block EXCEPT the option list itself, in document order. `[]` for a question with no body. */
|
|
124
|
+
readonly bodyNodes: readonly SteeringViewNode[]
|
|
125
|
+
/** `text`'s own inline counterpart, image-collapsed (R2) — feeds the question node's `detailInline`. Built over the SAME body run and `skipListItem` option as `bodyNodes`/`text`. */
|
|
126
|
+
readonly bodyInline: readonly InlineNode[]
|
|
127
|
+
readonly headingLine: number
|
|
128
|
+
/** Checkbox options in document order. `[]` for an ANSWERED question (prose, no checkboxes). The LAST option is the free-text slot. */
|
|
129
|
+
readonly options: readonly QuestionOption[]
|
|
130
|
+
/**
|
|
131
|
+
* `true` when this OPEN question is fully answered: EXACTLY ONE option is
|
|
132
|
+
* ticked, and if that's the free-text slot its text is non-empty. Always
|
|
133
|
+
* `false` for a question with no options and for an answered question.
|
|
134
|
+
*/
|
|
135
|
+
readonly answered: boolean
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
interface OpenQuestionsDoc {
|
|
139
|
+
readonly questions: readonly OpenQuestion[]
|
|
140
|
+
readonly findings: readonly SteeringFinding[]
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* `qa`'s own `headingText`: the shared mdast helper (`MarkdownTree.ts`), with
|
|
145
|
+
* `qa`'s extra pass of orphan `[^name]`-marker stripping — the one thing
|
|
146
|
+
* `qa` needs beyond the shared footnote-reference exclusion every format
|
|
147
|
+
* gets for free.
|
|
148
|
+
*/
|
|
149
|
+
const headingText = (content: string, heading: Heading): string =>
|
|
150
|
+
sharedHeadingText(content, heading, stripMarkerText)
|
|
151
|
+
|
|
152
|
+
/** One `###` heading node under a questions section, with its raw body block nodes (up to the next heading of any level). */
|
|
153
|
+
interface QuestionBlock extends HeadingBlock {}
|
|
154
|
+
|
|
155
|
+
/** Splits the nodes after a `## ... Questions` heading into consecutive `###` blocks. A bare `###` with no children is still collected — `parseQuestionBlock` is the one place that turns it into a finding. */
|
|
156
|
+
const splitQuestionBlocks = (tree: Root, sectionHeadingIndex: number): readonly QuestionBlock[] => {
|
|
157
|
+
const blocks: QuestionBlock[] = []
|
|
158
|
+
let i = sectionHeadingIndex + 1
|
|
159
|
+
|
|
160
|
+
while (i < tree.children.length) {
|
|
161
|
+
const node = tree.children[i]!
|
|
162
|
+
if (node.type === "heading" && node.depth <= 2) break
|
|
163
|
+
|
|
164
|
+
if (node.type !== "heading" || node.depth !== 3) {
|
|
165
|
+
i += 1
|
|
166
|
+
continue
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const heading = node
|
|
170
|
+
i += 1
|
|
171
|
+
const body: RootContent[] = []
|
|
172
|
+
while (i < tree.children.length && tree.children[i]!.type !== "heading") {
|
|
173
|
+
body.push(tree.children[i]!)
|
|
174
|
+
i += 1
|
|
175
|
+
}
|
|
176
|
+
blocks.push({ heading, body })
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return blocks
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Every task-list `listItem` in the TOP-LEVEL lists of a question's body, in
|
|
184
|
+
* document order. A NESTED task-list item is deliberately not an option —
|
|
185
|
+
* this format has no sub-option — and `recognizedStructureLines` does NOT
|
|
186
|
+
* shield it from `strictReadingFindings`, so a 4+-space nested option gets a
|
|
187
|
+
* positioned finding rather than silently vanishing.
|
|
188
|
+
*/
|
|
189
|
+
const optionListItems = (body: readonly RootContent[]): readonly ListItem[] => {
|
|
190
|
+
const items: ListItem[] = []
|
|
191
|
+
for (const node of body) {
|
|
192
|
+
if (node.type !== "list") continue
|
|
193
|
+
for (const child of (node as List).children) {
|
|
194
|
+
if (child.checked !== null) items.push(child)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return items
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* A question's body run through the shared block walk, with the question's
|
|
202
|
+
* own option ITEMS excluded by node identity — never a whole list swept out
|
|
203
|
+
* because some of its children are options, so a plain bullet sharing a list
|
|
204
|
+
* with real options still surfaces in `body`. `footnotes` is
|
|
205
|
+
* `parseOpenQuestions`'s SINGLE `parseFootnotes` call, threaded in: a
|
|
206
|
+
* re-parse here would be a whole-document walk per question and per option.
|
|
207
|
+
*/
|
|
208
|
+
const questionBodyNodes = (
|
|
209
|
+
content: string,
|
|
210
|
+
body: readonly RootContent[],
|
|
211
|
+
footnotes: Footnotes,
|
|
212
|
+
): readonly SteeringViewNode[] => {
|
|
213
|
+
const optionItems = new Set(optionListItems(body))
|
|
214
|
+
return blockNodesOfRun(content, body, {
|
|
215
|
+
skipListItem: (item) => optionItems.has(item),
|
|
216
|
+
footnotes,
|
|
217
|
+
})
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** `questionBodyNodes`'s own inline counterpart — the SAME body run and `skipListItem` option, walked by `Blocks.ts#blockRunInline` instead, then image-collapsed (R2) for the question node's `detailInline`. */
|
|
221
|
+
const questionBodyInline = (
|
|
222
|
+
content: string,
|
|
223
|
+
body: readonly RootContent[],
|
|
224
|
+
): readonly InlineNode[] => {
|
|
225
|
+
const optionItems = new Set(optionListItems(body))
|
|
226
|
+
return collapseImages(
|
|
227
|
+
blockRunInline(content, body, { skipListItem: (item) => optionItems.has(item) }),
|
|
228
|
+
)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** The whole body's collapsed text — every `bodyNodes` block's own title, joined with a single space, or `""` for an empty body. Feeds `OpenQuestion.text`. */
|
|
232
|
+
const bodyText = (bodyNodes: readonly SteeringViewNode[]): string =>
|
|
233
|
+
bodyNodes
|
|
234
|
+
.map((node) => node.title)
|
|
235
|
+
.filter((title) => title.length > 0)
|
|
236
|
+
.join(" ")
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* The source offset right after an option's `- [ ]`/`- [x]` marker, read off
|
|
240
|
+
* the first inline CHILD rather than the paragraph node:
|
|
241
|
+
* `mdast-util-gfm-task-list-item` leaves a checked item's paragraph with a
|
|
242
|
+
* stale `position.start` when what remains starts with a non-text inline node.
|
|
243
|
+
*/
|
|
244
|
+
const optionContentOffset = (item: ListItem): number | undefined => {
|
|
245
|
+
const paragraph = item.children.find((c) => c.type === "paragraph")
|
|
246
|
+
return paragraph?.children[0]?.position?.start.offset
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* The ONE span an option's text is read from and written back over: after the
|
|
251
|
+
* marker through the end of the item's FIRST PARAGRAPH, never the list item's
|
|
252
|
+
* end. A wrapped option is one paragraph, so the wrap is covered; a nested
|
|
253
|
+
* list, second paragraph or footnote definition sits outside it and a
|
|
254
|
+
* free-text save can never delete it. Both the reader and the writer call
|
|
255
|
+
* this, so the two can never drift into that corruption.
|
|
256
|
+
*/
|
|
257
|
+
const optionTextSpan = (
|
|
258
|
+
item: ListItem,
|
|
259
|
+
): { readonly start: number; readonly end: number } | undefined => {
|
|
260
|
+
const start = optionContentOffset(item)
|
|
261
|
+
const paragraph = item.children.find((c) => c.type === "paragraph")
|
|
262
|
+
const end = paragraph?.position?.end.offset
|
|
263
|
+
if (start === undefined || end === undefined) return undefined
|
|
264
|
+
return { start, end }
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* An option's own text: the WHOLE `optionTextSpan`, marker-stripped and
|
|
269
|
+
* whitespace-collapsed — a wrapped option's continuation lines are part of
|
|
270
|
+
* this, unlike the old per-line-only capture. `""` when the option has no
|
|
271
|
+
* paragraph at all (a bare `- [ ]`).
|
|
272
|
+
*/
|
|
273
|
+
const optionText = (content: string, item: ListItem): string => {
|
|
274
|
+
const span = optionTextSpan(item)
|
|
275
|
+
if (!span) return ""
|
|
276
|
+
return stripMarkerText(content.slice(span.start, span.end)).replace(/\s+/g, " ").trim()
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* An option's own nested blocks — the list item's children with its label
|
|
281
|
+
* paragraph (the one `optionTextSpan`/`optionText` already read) excluded,
|
|
282
|
+
* run through the shared block walk (`Blocks.ts#blockNodesOfRun`). A nested
|
|
283
|
+
* plain bullet list under an option is real per-option content (`checked` is
|
|
284
|
+
* `null` on a plain bullet, exactly what `optionListItems` already filters
|
|
285
|
+
* on, so it can never be mistaken for a sub-option); a second paragraph under
|
|
286
|
+
* the option reads the same way. `[]` for an option with nothing nested under
|
|
287
|
+
* it. `footnotes` is `parseOpenQuestions`'s own SINGLE `parseFootnotes` call,
|
|
288
|
+
* threaded in rather than re-parsed here: `blockNodesOfRun`'s own footnote
|
|
289
|
+
* pass walks the WHOLE document, so a question with five options re-parsing
|
|
290
|
+
* it five times over (on top of the per-question pass `questionBodyNodes`
|
|
291
|
+
* already pays) would cost work proportional to the file, not to the
|
|
292
|
+
* option's own nested nodes.
|
|
293
|
+
*/
|
|
294
|
+
const optionBodyNodes = (
|
|
295
|
+
content: string,
|
|
296
|
+
item: ListItem,
|
|
297
|
+
footnotes: Footnotes,
|
|
298
|
+
): readonly SteeringViewNode[] => {
|
|
299
|
+
const labelParagraph = item.children.find((c) => c.type === "paragraph")
|
|
300
|
+
const rest = item.children.filter((c) => c !== labelParagraph)
|
|
301
|
+
return blockNodesOfRun(content, rest, { footnotes })
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Extracts the checkbox options from a question block's body, in document order. */
|
|
305
|
+
const parseOptions = (
|
|
306
|
+
content: string,
|
|
307
|
+
body: readonly RootContent[],
|
|
308
|
+
footnotes: Footnotes,
|
|
309
|
+
): QuestionOption[] => {
|
|
310
|
+
const items = optionListItems(body)
|
|
311
|
+
const lastIndex = items.length - 1
|
|
312
|
+
return items.map((item, i) => {
|
|
313
|
+
const freeText = i === lastIndex
|
|
314
|
+
const rawText = optionText(content, item)
|
|
315
|
+
// `.toLowerCase()` on BOTH sides — never assume `FREE_TEXT_PLACEHOLDER`
|
|
316
|
+
// itself is already lowercase, mirroring `Question.tsx`'s identical
|
|
317
|
+
// client-side comparison exactly, so the two can never silently diverge
|
|
318
|
+
// if the constant's own casing ever changes.
|
|
319
|
+
const text =
|
|
320
|
+
freeText && rawText.toLowerCase() === FREE_TEXT_PLACEHOLDER.toLowerCase() ? "" : rawText
|
|
321
|
+
return {
|
|
322
|
+
checked: item.checked === true,
|
|
323
|
+
text,
|
|
324
|
+
freeText,
|
|
325
|
+
sourceLine: toLspPosition(item.position!.start).line,
|
|
326
|
+
endLine: toLspPosition(item.position!.end).line,
|
|
327
|
+
body: optionBodyNodes(content, item, footnotes),
|
|
328
|
+
}
|
|
329
|
+
})
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** The three fields `isAnswered` reads, narrower than `QuestionOption` so a CLIENT can build this from its own radio state and call the identical predicate. */
|
|
333
|
+
export interface AnsweredOption {
|
|
334
|
+
readonly checked: boolean
|
|
335
|
+
readonly text: string
|
|
336
|
+
readonly freeText: boolean
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* An OPEN question is answered iff EXACTLY ONE option is ticked and, for the
|
|
341
|
+
* free-text slot, its normalized text is non-empty. Zero ticks, two+ ticks and
|
|
342
|
+
* a ticked-but-empty slot all read as not answered. The phone client calls
|
|
343
|
+
* this same function rather than recomputing the rule.
|
|
344
|
+
*/
|
|
345
|
+
export const isAnswered = (options: readonly AnsweredOption[]): boolean => {
|
|
346
|
+
const ticked = options.filter((o) => o.checked)
|
|
347
|
+
if (ticked.length !== 1) return false
|
|
348
|
+
const chosen = ticked[0]!
|
|
349
|
+
return !(chosen.freeText && chosen.text.length === 0)
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** A heading node's own span (the `#` run through its own line's end) as a `SteeringFinding.range` — the NODE a heading-shaped finding is about. */
|
|
353
|
+
const headingRange = (heading: Heading) => ({
|
|
354
|
+
start: toLspPosition(heading.position!.start),
|
|
355
|
+
end: toLspPosition(heading.position!.end),
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
const parseQuestionBlock = (
|
|
359
|
+
content: string,
|
|
360
|
+
block: QuestionBlock,
|
|
361
|
+
status: OpenQuestionStatus,
|
|
362
|
+
footnotes: Footnotes,
|
|
363
|
+
): OpenQuestion | { readonly error: SteeringFinding } => {
|
|
364
|
+
const question = headingText(content, block.heading)
|
|
365
|
+
if (question.length === 0) {
|
|
366
|
+
return {
|
|
367
|
+
error: {
|
|
368
|
+
message:
|
|
369
|
+
"An '### ' question heading under '## Open Questions' or '## Answered Questions' has no question text",
|
|
370
|
+
line: toLspPosition(block.heading.position!.start).line,
|
|
371
|
+
range: headingRange(block.heading),
|
|
372
|
+
},
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const headingLine = toLspPosition(block.heading.position!.start).line
|
|
377
|
+
const bodyNodes = questionBodyNodes(content, block.body, footnotes)
|
|
378
|
+
const bodyInline = questionBodyInline(content, block.body)
|
|
379
|
+
const options = status === "open" ? parseOptions(content, block.body, footnotes) : []
|
|
380
|
+
|
|
381
|
+
return {
|
|
382
|
+
question,
|
|
383
|
+
status,
|
|
384
|
+
text: bodyText(bodyNodes),
|
|
385
|
+
bodyNodes,
|
|
386
|
+
bodyInline,
|
|
387
|
+
headingLine,
|
|
388
|
+
options,
|
|
389
|
+
answered: status === "open" && options.length > 0 && isAnswered(options),
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* `## Open Questions` first, `## Answered Questions` last, so a reader always
|
|
395
|
+
* finds open questions before resolved ones. At most one finding per rule.
|
|
396
|
+
* Walks `heading` NODES, never a string search, so the line quoted inside a
|
|
397
|
+
* fenced code block never counts as the section.
|
|
398
|
+
*
|
|
399
|
+
* Known gap: a `## Open Questions` heading itself indented 4+ spaces parses as
|
|
400
|
+
* indented code, so the whole section goes unrecognized with no finding —
|
|
401
|
+
* `strictReadingFindings` covers the `### ` and `- [ ]` shapes, not this one.
|
|
402
|
+
*/
|
|
403
|
+
const checkSectionOrder = (tree: Root, content: string): readonly SteeringFinding[] => {
|
|
404
|
+
const h2 = tree.children.filter((n): n is Heading => n.type === "heading" && n.depth === 2)
|
|
405
|
+
const openIndex = h2.findIndex((h) => headingText(content, h) === "Open Questions")
|
|
406
|
+
const answeredIndex = h2.findIndex((h) => headingText(content, h) === "Answered Questions")
|
|
407
|
+
|
|
408
|
+
const findings: SteeringFinding[] = []
|
|
409
|
+
// `openIndex > 0` — a section exists BEFORE it — is the whole condition;
|
|
410
|
+
// `i !== openIndex` was always true whenever `i < openIndex` already held,
|
|
411
|
+
// so it added nothing (a guaranteed-surviving mutant on the dead clause).
|
|
412
|
+
if (openIndex > 0) {
|
|
413
|
+
const offender = h2[0]!
|
|
414
|
+
findings.push({
|
|
415
|
+
message: "A '##' section appears before '## Open Questions', which must come first",
|
|
416
|
+
line: toLspPosition(offender.position!.start).line,
|
|
417
|
+
range: headingRange(offender),
|
|
418
|
+
})
|
|
419
|
+
}
|
|
420
|
+
// Same simplification: `answeredIndex < h2.length - 1` — a section exists
|
|
421
|
+
// AFTER it — is the whole condition.
|
|
422
|
+
if (answeredIndex !== -1 && answeredIndex < h2.length - 1) {
|
|
423
|
+
const offender = h2[h2.length - 1]!
|
|
424
|
+
findings.push({
|
|
425
|
+
message: "A '##' section appears after '## Answered Questions', which must come last",
|
|
426
|
+
line: toLspPosition(offender.position!.start).line,
|
|
427
|
+
range: headingRange(offender),
|
|
428
|
+
})
|
|
429
|
+
}
|
|
430
|
+
return findings
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** A line shaped like a '### ' question heading, indented past what CommonMark still parses as a real heading — used only by `strictReadingFindings` to recognize what fell through. */
|
|
434
|
+
const HEADING_SHAPE_RE = /^#{3}(?:\s|$)/
|
|
435
|
+
|
|
436
|
+
/** A line shaped like a '- [ ]'/'- [x]'/'* [X]' task-list option, indented past what CommonMark still parses as a real list item — used only by `strictReadingFindings` to recognize what fell through. */
|
|
437
|
+
const OPTION_SHAPE_RE = /^[-*]\s*\[[ xX]\]/
|
|
438
|
+
|
|
439
|
+
/** FENCED, never indented — both parse to the same `code` node, so telling them apart means reading the opening delimiter back out of the source. A fenced block quoting `### ` is an example, not a dropped heading. */
|
|
440
|
+
const isFencedCode = (content: string, node: Code): boolean => {
|
|
441
|
+
if (!node.position) return false
|
|
442
|
+
const lines = content.split(/\r?\n/)
|
|
443
|
+
const raw = (lines[toLspPosition(node.position.start).line] ?? "").trim()
|
|
444
|
+
return raw.startsWith("```") || raw.startsWith("~~~")
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Every 0-based line inside a NODE matching `predicate`, at any nesting
|
|
449
|
+
* depth — used by `fencedCodeLines` (a fenced block is a legitimate quoted
|
|
450
|
+
* example regardless of how deep it's nested).
|
|
451
|
+
*/
|
|
452
|
+
const linesWhere = (tree: Root, predicate: (node: RootContent) => boolean): Set<number> => {
|
|
453
|
+
const lines = new Set<number>()
|
|
454
|
+
const walk = (node: RootContent | Root): void => {
|
|
455
|
+
if (node.type !== "root" && predicate(node) && node.position) {
|
|
456
|
+
const start = toLspPosition(node.position.start).line
|
|
457
|
+
const end = toLspPosition(node.position.end).line
|
|
458
|
+
for (let l = start; l <= end; l += 1) lines.add(l)
|
|
459
|
+
}
|
|
460
|
+
const children = (node as { children?: readonly RootContent[] }).children
|
|
461
|
+
if (children) children.forEach(walk)
|
|
462
|
+
}
|
|
463
|
+
walk(tree)
|
|
464
|
+
return lines
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const markRange = (lines: Set<number>, node: RootContent): void => {
|
|
468
|
+
if (!node.position) return
|
|
469
|
+
const start = toLspPosition(node.position.start).line
|
|
470
|
+
const end = toLspPosition(node.position.end).line
|
|
471
|
+
for (let l = start; l <= end; l += 1) lines.add(l)
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** Lines inside a NESTED `heading`/`list` under a top-level item. Real tree structure, but lost from this format's OUTPUT, so `recognizedStructureLines` must not excuse it just for sitting inside a real item's span. */
|
|
475
|
+
const nestedBlockLines = (item: ListItem): Set<number> => {
|
|
476
|
+
const lines = new Set<number>()
|
|
477
|
+
const walk = (node: RootContent): void => {
|
|
478
|
+
if (node.type === "heading" || node.type === "list") {
|
|
479
|
+
markRange(lines, node)
|
|
480
|
+
return
|
|
481
|
+
}
|
|
482
|
+
const children = (node as { children?: readonly RootContent[] }).children
|
|
483
|
+
if (children) children.forEach(walk)
|
|
484
|
+
}
|
|
485
|
+
item.children.forEach(walk)
|
|
486
|
+
return lines
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** Marks a top-level `list` node's own TOP-LEVEL task-list items into `lines`, each minus its own `nestedBlockLines` — split out of `recognizedStructureLines` to keep that function's own complexity down. */
|
|
490
|
+
const markTopLevelListItems = (lines: Set<number>, list: List): void => {
|
|
491
|
+
for (const item of list.children) {
|
|
492
|
+
if (item.checked === null) continue
|
|
493
|
+
markRange(lines, item)
|
|
494
|
+
for (const nested of nestedBlockLines(item)) lines.delete(nested)
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* The lines `strictReadingFindings` never flags: a TOP-LEVEL depth-2/3
|
|
500
|
+
* heading, a TOP-LEVEL task-list item's span MINUS any `heading`/`list`
|
|
501
|
+
* nested inside it, and a `footnoteDefinition`'s whole span.
|
|
502
|
+
*
|
|
503
|
+
* Deeper nesting is deliberately NOT excluded, however validly CommonMark
|
|
504
|
+
* parses it: `optionListItems` never looks past the top level, so that
|
|
505
|
+
* content is as lost from this format's output as an unindented line.
|
|
506
|
+
*
|
|
507
|
+
* A footnote definition differs in kind, not degree — the human's free-text
|
|
508
|
+
* channel, never a candidate heading or option under any reading — so its
|
|
509
|
+
* whole span is excluded at any nesting. This repo's own footnote style
|
|
510
|
+
* indents continuation lines four spaces, exactly the misfire threshold.
|
|
511
|
+
*/
|
|
512
|
+
const recognizedStructureLines = (tree: Root): Set<number> => {
|
|
513
|
+
const lines = new Set<number>()
|
|
514
|
+
for (const node of tree.children) {
|
|
515
|
+
if (node.type === "heading" && (node.depth === 2 || node.depth === 3)) markRange(lines, node)
|
|
516
|
+
if (node.type === "list") markTopLevelListItems(lines, node)
|
|
517
|
+
if (node.type === "footnoteDefinition") markRange(lines, node)
|
|
518
|
+
}
|
|
519
|
+
return lines
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** Every 0-based line inside a FENCED code block — a legitimate quoted example, never a strict-reading violation (unlike an indented code block, or an indented lazy paragraph continuation, which the refusal exists to catch). */
|
|
523
|
+
const fencedCodeLines = (tree: Root, content: string): Set<number> =>
|
|
524
|
+
linesWhere(tree, (n) => n.type === "code" && isFencedCode(content, n))
|
|
525
|
+
|
|
526
|
+
/** A depth-2 section's body line range. Line-based, not node-based: the point is to catch source that never became a distinct node, so there is no node boundary to walk. */
|
|
527
|
+
const sectionLineRange = (
|
|
528
|
+
tree: Root,
|
|
529
|
+
content: string,
|
|
530
|
+
heading: Heading,
|
|
531
|
+
): { readonly start: number; readonly end: number } => {
|
|
532
|
+
const lines = content.split(/\r?\n/)
|
|
533
|
+
const index = tree.children.indexOf(heading)
|
|
534
|
+
const start = toLspPosition(heading.position!.end).line + 1
|
|
535
|
+
let end = lines.length - 1
|
|
536
|
+
for (let i = index + 1; i < tree.children.length; i += 1) {
|
|
537
|
+
const node = tree.children[i]!
|
|
538
|
+
if (node.type === "heading" && node.depth <= 2 && node.position) {
|
|
539
|
+
end = toLspPosition(node.position.start).line - 1
|
|
540
|
+
break
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
return { start, end }
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/** The strict-reading message for one dropped `trimmed` line, or `undefined` when it matches neither shape — split out of `strictReadingFindings` to keep that function's own complexity down. */
|
|
547
|
+
const strictReadingMessage = (trimmed: string): string | undefined => {
|
|
548
|
+
if (HEADING_SHAPE_RE.test(trimmed)) {
|
|
549
|
+
return `An indented (4+ space) "${trimmed}" is markdown indented code (or a lazy paragraph continuation), not a question heading — it is silently dropped otherwise`
|
|
550
|
+
}
|
|
551
|
+
if (OPTION_SHAPE_RE.test(trimmed)) {
|
|
552
|
+
return `An indented (4+ space) "${trimmed}" is markdown indented code (or a lazy paragraph continuation), not an option — it is silently dropped otherwise`
|
|
553
|
+
}
|
|
554
|
+
return undefined
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** Every strict-reading finding in ONE section's own `[start, end]` line range — split out of `strictReadingFindings` to keep that function's own complexity down (one section's scan, not the loop over both sections). */
|
|
558
|
+
const strictReadingFindingsInRange = (
|
|
559
|
+
lines: readonly string[],
|
|
560
|
+
excluded: ReadonlySet<number>,
|
|
561
|
+
start: number,
|
|
562
|
+
end: number,
|
|
563
|
+
): SteeringFinding[] => {
|
|
564
|
+
const findings: SteeringFinding[] = []
|
|
565
|
+
for (let line = start; line <= end; line += 1) {
|
|
566
|
+
if (excluded.has(line)) continue
|
|
567
|
+
const raw = lines[line] ?? ""
|
|
568
|
+
if (raw.length - raw.trimStart().length < 4) continue
|
|
569
|
+
const message = strictReadingMessage(raw.trim())
|
|
570
|
+
// No real node exists for this line (that's the whole violation — the
|
|
571
|
+
// content never became one) so its range is the raw line itself, start
|
|
572
|
+
// to end, rather than a node span.
|
|
573
|
+
if (message) {
|
|
574
|
+
findings.push({
|
|
575
|
+
message,
|
|
576
|
+
line,
|
|
577
|
+
range: { start: { line, character: 0 }, end: { line, character: raw.length } },
|
|
578
|
+
})
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
return findings
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* A `### `- or `- [ ]`-shaped line indented 4+ spaces is never a real heading
|
|
586
|
+
* or list item — it is indented code, or a lazy paragraph continuation — so
|
|
587
|
+
* without this it vanishes with no signal: a whole question silently dropped,
|
|
588
|
+
* or one left with zero options and read as merely unanswered. Reported at the
|
|
589
|
+
* exact source line, whichever of the two swallowed it.
|
|
590
|
+
*/
|
|
591
|
+
const strictReadingFindings = (tree: Root, content: string): SteeringFinding[] => {
|
|
592
|
+
const lines = content.split(/\r?\n/)
|
|
593
|
+
const excluded = new Set([...recognizedStructureLines(tree), ...fencedCodeLines(tree, content)])
|
|
594
|
+
|
|
595
|
+
return ["Open Questions", "Answered Questions"].flatMap((sectionName) => {
|
|
596
|
+
const heading = tree.children.find(
|
|
597
|
+
(n): n is Heading =>
|
|
598
|
+
n.type === "heading" && n.depth === 2 && headingText(content, n) === sectionName,
|
|
599
|
+
)
|
|
600
|
+
if (!heading?.position) return []
|
|
601
|
+
const { start, end } = sectionLineRange(tree, content, heading)
|
|
602
|
+
return strictReadingFindingsInRange(lines, excluded, start, end)
|
|
603
|
+
})
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* The structure plus every finding `validate` reports. Each finding carries a
|
|
608
|
+
* `line` AND a `range` spanning its node — except `strictReadingFindings`',
|
|
609
|
+
* which never had a node, so their range spans the raw offending line.
|
|
610
|
+
*
|
|
611
|
+
* `parseFootnotes(content)` runs exactly ONCE here and is threaded through
|
|
612
|
+
* every question/option body walk below: re-running it per question or per
|
|
613
|
+
* option would multiply a document-sized walk by every one in the file.
|
|
614
|
+
*/
|
|
615
|
+
export const parseOpenQuestions = (content: string): OpenQuestionsDoc => {
|
|
616
|
+
const tree = parseMarkdown(content)
|
|
617
|
+
const footnotes = parseFootnotes(content)
|
|
618
|
+
|
|
619
|
+
const questions: OpenQuestion[] = []
|
|
620
|
+
const findings: SteeringFinding[] = [
|
|
621
|
+
...checkSectionOrder(tree, content),
|
|
622
|
+
...strictReadingFindings(tree, content),
|
|
623
|
+
]
|
|
624
|
+
|
|
625
|
+
const sections: readonly (readonly [string, OpenQuestionStatus])[] = [
|
|
626
|
+
["Open Questions", "open"],
|
|
627
|
+
["Answered Questions", "answered"],
|
|
628
|
+
]
|
|
629
|
+
|
|
630
|
+
for (const [sectionName, status] of sections) {
|
|
631
|
+
const headingNode = tree.children.find(
|
|
632
|
+
(n): n is Heading =>
|
|
633
|
+
n.type === "heading" && n.depth === 2 && headingText(content, n) === sectionName,
|
|
634
|
+
)
|
|
635
|
+
if (!headingNode) continue
|
|
636
|
+
const index = tree.children.indexOf(headingNode)
|
|
637
|
+
for (const block of splitQuestionBlocks(tree, index)) {
|
|
638
|
+
const result = parseQuestionBlock(content, block, status, footnotes)
|
|
639
|
+
if ("error" in result) {
|
|
640
|
+
findings.push(result.error)
|
|
641
|
+
} else {
|
|
642
|
+
questions.push(result)
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
questions.sort((a, b) => a.headingLine - b.headingLine)
|
|
648
|
+
return { questions, findings }
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/** Every OPEN question that is not answered — what a flow's `openQuestions()` reads. */
|
|
652
|
+
const unansweredQuestions = (content: string): readonly OpenQuestion[] =>
|
|
653
|
+
parseOpenQuestions(content).questions.filter((q) => q.status === "open" && !q.answered)
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Flips the checkbox on the task-list item at `line`, preserving the rest
|
|
657
|
+
* exactly. The box is found in the window between the item's start offset and
|
|
658
|
+
* its content offset — which holds only the list marker and the box — rather
|
|
659
|
+
* than by `indexOf("[")`, which text containing its own `[` would mislead.
|
|
660
|
+
*/
|
|
661
|
+
const toggleCheckbox = (content: string, line: number): SteeringEdit | undefined => {
|
|
662
|
+
const tree = parseMarkdown(content)
|
|
663
|
+
const item = taskItems(tree).find((it) => toLspPosition(it.position!.start).line === line)
|
|
664
|
+
if (!item?.position) return undefined
|
|
665
|
+
|
|
666
|
+
const startOffset = item.position.start.offset!
|
|
667
|
+
const boundOffset = optionContentOffset(item) ?? item.position.end.offset!
|
|
668
|
+
const bracketOffset = content.indexOf("[", startOffset)
|
|
669
|
+
if (bracketOffset === -1 || bracketOffset >= boundOffset) return undefined
|
|
670
|
+
|
|
671
|
+
const position = toLspPositionFromOffset(content, bracketOffset)
|
|
672
|
+
const character = position.character + 1
|
|
673
|
+
return {
|
|
674
|
+
range: {
|
|
675
|
+
start: { line: position.line, character },
|
|
676
|
+
end: { line: position.line, character: character + 1 },
|
|
677
|
+
},
|
|
678
|
+
newText: item.checked === true ? " " : "x",
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/** The outline marker for one question — `[unanswered]` entries are exactly the questions still blocking the answer-completeness gate. */
|
|
683
|
+
const statusMarker = (question: OpenQuestion): string => {
|
|
684
|
+
if (question.status === "answered") return "[answered]"
|
|
685
|
+
return question.answered ? "[answered]" : "[unanswered]"
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/** Every question/answered heading's own line → its outline range's real end line (`blockEndLine`), across both sections — the node-boundary replacement for the old "next sibling's heading minus one" guess. */
|
|
689
|
+
const questionEndLines = (content: string): ReadonlyMap<number, number> => {
|
|
690
|
+
const tree = parseMarkdown(content)
|
|
691
|
+
const map = new Map<number, number>()
|
|
692
|
+
for (const sectionName of ["Open Questions", "Answered Questions"]) {
|
|
693
|
+
const headingNode = tree.children.find(
|
|
694
|
+
(n): n is Heading =>
|
|
695
|
+
n.type === "heading" && n.depth === 2 && headingText(content, n) === sectionName,
|
|
696
|
+
)
|
|
697
|
+
if (!headingNode) continue
|
|
698
|
+
const index = tree.children.indexOf(headingNode)
|
|
699
|
+
for (const block of splitQuestionBlocks(tree, index)) {
|
|
700
|
+
map.set(toLspPosition(block.heading.position!.start).line, blockEndLine(block))
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
return map
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/** The outline tree. An option is a `leaf` unless it carries a footnote, which makes it a container instead — `leaf` and `children` are never both set. */
|
|
707
|
+
const questionsOutline = (content: string): readonly SteeringOutlineNode[] => {
|
|
708
|
+
const { questions } = parseOpenQuestions(content)
|
|
709
|
+
const { markers, definitions } = parseFootnotes(content)
|
|
710
|
+
const definitionByName = new Map(definitions.map((d) => [d.name, d.body]))
|
|
711
|
+
const lines = content.split(/\r?\n/)
|
|
712
|
+
const endLines = questionEndLines(content)
|
|
713
|
+
const threads = threadOutlineNodes(content)
|
|
714
|
+
const nested = new Set<SteeringOutlineNode>()
|
|
715
|
+
const threadsIn = (from: number, to: number): SteeringOutlineNode[] =>
|
|
716
|
+
threads
|
|
717
|
+
.filter((t) => t.markerLine !== undefined && t.markerLine >= from && t.markerLine <= to)
|
|
718
|
+
.map((t) => t.node)
|
|
719
|
+
const nodes = questions.map((question) => {
|
|
720
|
+
const start = question.headingLine
|
|
721
|
+
const end = Math.max(start, endLines.get(start) ?? start)
|
|
722
|
+
const questionMarkers = markers.filter((m) => m.line >= start && m.line <= end)
|
|
723
|
+
const assigned = new Set<FootnoteMarker>()
|
|
724
|
+
|
|
725
|
+
const optionChildren: SteeringOutlineNode[] = question.options.map((option) => {
|
|
726
|
+
const optionMarkers = questionMarkers.filter(
|
|
727
|
+
(m) => m.line >= option.sourceLine && m.line <= option.endLine,
|
|
728
|
+
)
|
|
729
|
+
optionMarkers.forEach((m) => assigned.add(m))
|
|
730
|
+
const optionThreads = threadsIn(option.sourceLine, option.endLine)
|
|
731
|
+
optionThreads.forEach((n) => nested.add(n))
|
|
732
|
+
const footnotes = [
|
|
733
|
+
...optionMarkers.map((m) => footnoteLeaf(lines, definitionByName, m)),
|
|
734
|
+
...optionThreads,
|
|
735
|
+
]
|
|
736
|
+
const impactsDetail = bodyText(option.body)
|
|
737
|
+
return {
|
|
738
|
+
name: `${option.checked ? "[x]" : "[ ]"} ${option.text || "your answer"}`,
|
|
739
|
+
...(impactsDetail.length > 0 ? { detail: impactsDetail } : {}),
|
|
740
|
+
range: spanRange(lines, option.sourceLine, option.endLine),
|
|
741
|
+
selectionRange: lineRange(lines, option.sourceLine),
|
|
742
|
+
// `leaf: true` means "no children of its own" (SteeringFormat.ts) —
|
|
743
|
+
// an option with a footnote child is no longer one, matching
|
|
744
|
+
// review.ts's chunk nodes (children, no `leaf`).
|
|
745
|
+
...(footnotes.length > 0 ? { children: footnotes } : { leaf: true }),
|
|
746
|
+
}
|
|
747
|
+
})
|
|
748
|
+
|
|
749
|
+
const questionFootnotes = questionMarkers
|
|
750
|
+
.filter((m) => !assigned.has(m))
|
|
751
|
+
.map((m) => footnoteLeaf(lines, definitionByName, m))
|
|
752
|
+
const questionThreads = threadsIn(start, end).filter((n) => !nested.has(n))
|
|
753
|
+
questionThreads.forEach((n) => nested.add(n))
|
|
754
|
+
const children = [...optionChildren, ...questionFootnotes, ...questionThreads]
|
|
755
|
+
|
|
756
|
+
return {
|
|
757
|
+
name: `${statusMarker(question)} ${question.question}`,
|
|
758
|
+
detail: question.text,
|
|
759
|
+
range: spanRange(lines, start, end),
|
|
760
|
+
selectionRange: lineRange(lines, start),
|
|
761
|
+
...(children.length > 0 ? { children } : {}),
|
|
762
|
+
}
|
|
763
|
+
})
|
|
764
|
+
return [...nodes, ...threads.map((t) => t.node).filter((n) => !nested.has(n))]
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/** The edits that make `option` the sole ticked option in `question` (radio semantics): check it, and uncheck any already-ticked sibling — so the question ends with exactly one tick (what the completeness gate wants). */
|
|
768
|
+
const pickOptionEdits = (
|
|
769
|
+
content: string,
|
|
770
|
+
question: OpenQuestion,
|
|
771
|
+
option: QuestionOption,
|
|
772
|
+
): SteeringEdit[] => {
|
|
773
|
+
const edits: SteeringEdit[] = []
|
|
774
|
+
for (const sibling of question.options) {
|
|
775
|
+
if (sibling.sourceLine !== option.sourceLine && !sibling.checked) continue
|
|
776
|
+
const edit = toggleCheckbox(content, sibling.sourceLine)
|
|
777
|
+
if (edit) edits.push(edit)
|
|
778
|
+
}
|
|
779
|
+
return edits
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/** Sets an explicit `checked` STATE, unlike `pickOptionEdits`, which assumes the target is not already ticked. `true` is radio (unticks every sibling); `false` only unticks the target. */
|
|
783
|
+
const setOptionCheckedEdits = (
|
|
784
|
+
content: string,
|
|
785
|
+
question: OpenQuestion,
|
|
786
|
+
option: QuestionOption,
|
|
787
|
+
checked: boolean,
|
|
788
|
+
): SteeringEdit[] => {
|
|
789
|
+
if (!checked) {
|
|
790
|
+
if (!option.checked) return []
|
|
791
|
+
const edit = toggleCheckbox(content, option.sourceLine)
|
|
792
|
+
return edit ? [edit] : []
|
|
793
|
+
}
|
|
794
|
+
const edits: SteeringEdit[] = []
|
|
795
|
+
for (const sibling of question.options) {
|
|
796
|
+
const isTarget = sibling.sourceLine === option.sourceLine
|
|
797
|
+
if (isTarget ? sibling.checked : !sibling.checked) continue
|
|
798
|
+
const edit = toggleCheckbox(content, sibling.sourceLine)
|
|
799
|
+
if (edit) edits.push(edit)
|
|
800
|
+
}
|
|
801
|
+
return edits
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/** Replaces the WHOLE `optionTextSpan`, wrap and all — used only for the free-text slot, whose placeholder or prior answer the typed answer replaces in place. */
|
|
805
|
+
const replaceOptionTextEdit = (
|
|
806
|
+
content: string,
|
|
807
|
+
item: ListItem,
|
|
808
|
+
text: string,
|
|
809
|
+
): SteeringEdit | undefined => {
|
|
810
|
+
const span = optionTextSpan(item)
|
|
811
|
+
if (!span) return undefined
|
|
812
|
+
return {
|
|
813
|
+
range: {
|
|
814
|
+
start: toLspPositionFromOffset(content, span.start),
|
|
815
|
+
end: toLspPositionFromOffset(content, span.end),
|
|
816
|
+
},
|
|
817
|
+
newText: text,
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/**
|
|
822
|
+
* Collapses a typed answer to the single-line shape a list-item label must
|
|
823
|
+
* be. A raw textarea value's interior newlines would break `.gtd/`'s oxfmt
|
|
824
|
+
* fixed point the moment they are spliced onto a `- [x] ` line, and every
|
|
825
|
+
* steering file — server-written ones included — is covered by `format:check`.
|
|
826
|
+
*/
|
|
827
|
+
/**
|
|
828
|
+
* Empty normalizes to `FREE_TEXT_PLACEHOLDER`, never `""`: an empty label
|
|
829
|
+
* leaves nothing after the marker, which `optionContentOffset` cannot find an
|
|
830
|
+
* offset for, breaking this anchor's `apply` forever. The placeholder reads
|
|
831
|
+
* back as `""`, so an erase still reads as unanswered.
|
|
832
|
+
*/
|
|
833
|
+
const normalizeFreeTextAnswer = (text: string): string => {
|
|
834
|
+
const normalized = text.replace(/\s+/g, " ").trim()
|
|
835
|
+
return normalized.length === 0 ? FREE_TEXT_PLACEHOLDER : normalized
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
/** Only the `option` anchor resolves here. `checked` defaults to `true`, and `text` is normalized and replaces the label in the SAME edit set as the tick — never a second call, never the raw textarea value. */
|
|
839
|
+
const questionsApply: SteeringFormat["apply"] = (content, anchor, opts) => {
|
|
840
|
+
if (anchor.kind !== "option") return { ok: false, reason: "anchor-not-found" }
|
|
841
|
+
const { questions } = parseOpenQuestions(content)
|
|
842
|
+
const question = questions[anchor.questionIndex]
|
|
843
|
+
const option = question?.options[anchor.index]
|
|
844
|
+
if (!question || !option) return { ok: false, reason: "anchor-not-found" }
|
|
845
|
+
|
|
846
|
+
const checked = opts.checked ?? true
|
|
847
|
+
const edits = setOptionCheckedEdits(content, question, option, checked)
|
|
848
|
+
|
|
849
|
+
if (opts.text !== undefined) {
|
|
850
|
+
const tree = parseMarkdown(content)
|
|
851
|
+
const item = taskItems(tree).find(
|
|
852
|
+
(it) => toLspPosition(it.position!.start).line === option.sourceLine,
|
|
853
|
+
)
|
|
854
|
+
const textEdit = item
|
|
855
|
+
? replaceOptionTextEdit(content, item, normalizeFreeTextAnswer(opts.text))
|
|
856
|
+
: undefined
|
|
857
|
+
if (textEdit) edits.push(textEdit)
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
return { ok: true, edits }
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
/** The single action for the option line the cursor sits on: uncheck it if ticked, else pick it (radio). `undefined` when there is no edit to make. */
|
|
864
|
+
const optionAction = (
|
|
865
|
+
content: string,
|
|
866
|
+
question: OpenQuestion,
|
|
867
|
+
option: QuestionOption,
|
|
868
|
+
): { readonly title: string; readonly edits: readonly SteeringEdit[] } | undefined => {
|
|
869
|
+
const edits = option.checked
|
|
870
|
+
? [toggleCheckbox(content, option.sourceLine)].filter((e): e is SteeringEdit => e !== undefined)
|
|
871
|
+
: pickOptionEdits(content, question, option)
|
|
872
|
+
if (edits.length === 0) return undefined
|
|
873
|
+
return { title: option.checked ? "gtd: uncheck this option" : "gtd: pick this option", edits }
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
/**
|
|
877
|
+
* Where a footnote lands: the containing top-level block node's end line. One
|
|
878
|
+
* rule, not a case per shape — and resolving from the cursor's OWN node means
|
|
879
|
+
* prose between two option lists anchors to that prose, not the second list.
|
|
880
|
+
*/
|
|
881
|
+
const footnoteBlockEnd = (tree: Root, cursorLine: number): number => {
|
|
882
|
+
const block = blockNodeAt(tree, cursorLine)
|
|
883
|
+
return block?.position ? toLspPosition(block.position.end).line : cursorLine
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
/** Pick/uncheck on an open question's option; "add a footnote" LAST, after every option action, and everywhere EXCEPT inside an existing marker's span or on a definition's line, where a new marker would corrupt the footnote already there. */
|
|
887
|
+
const questionActions: SteeringFormat["actions"] = (content, range) => {
|
|
888
|
+
const { questions } = parseOpenQuestions(content)
|
|
889
|
+
const tree = parseMarkdown(content)
|
|
890
|
+
const cursorLine = range.start.line
|
|
891
|
+
const actions: Array<{ readonly title: string; readonly edits: readonly SteeringEdit[] }> = [
|
|
892
|
+
...threadReplyActions(content, range.start),
|
|
893
|
+
]
|
|
894
|
+
for (const question of questions) {
|
|
895
|
+
if (question.status !== "open") continue
|
|
896
|
+
const option = question.options.find(
|
|
897
|
+
(o) => cursorLine >= o.sourceLine && cursorLine <= o.endLine,
|
|
898
|
+
)
|
|
899
|
+
if (!option) continue
|
|
900
|
+
const action = optionAction(content, question, option)
|
|
901
|
+
if (action) actions.push(action)
|
|
902
|
+
}
|
|
903
|
+
if (!isOnExistingFootnote(content, range.start)) {
|
|
904
|
+
actions.push({
|
|
905
|
+
title: FOOTNOTE_ACTION_TITLE,
|
|
906
|
+
edits: footnoteAdditionEdits(content, range.start, footnoteBlockEnd(tree, cursorLine)),
|
|
907
|
+
})
|
|
908
|
+
}
|
|
909
|
+
return actions
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
/**
|
|
913
|
+
* `qa`'s `pointerAt`: footnote jumps only (an open question's options have
|
|
914
|
+
* nothing else to jump to) — marker → definition, definition → first marker,
|
|
915
|
+
* both within the same document.
|
|
916
|
+
*/
|
|
917
|
+
const questionsPointerAt: SteeringFormat["pointerAt"] = (content, position) =>
|
|
918
|
+
footnotePointerAt(content, position)?.pointer
|
|
919
|
+
|
|
920
|
+
/**
|
|
921
|
+
* `true` when top-level node `n` is the `## Open Questions`/`## Answered
|
|
922
|
+
* Questions` heading itself — the client renders those as its own section
|
|
923
|
+
* headers, so `blockNodesOf` never emits a block node for either.
|
|
924
|
+
*/
|
|
925
|
+
const isQuestionsSectionHeading = (content: string, n: RootContent): n is Heading =>
|
|
926
|
+
n.type === "heading" &&
|
|
927
|
+
n.depth === 2 &&
|
|
928
|
+
(headingText(content, n) === "Open Questions" || headingText(content, n) === "Answered Questions")
|
|
929
|
+
|
|
930
|
+
/**
|
|
931
|
+
* Every `[headingLine, endLine]` span a real question block owns, across
|
|
932
|
+
* both sections (`questionEndLines`, already computed for the outline) —
|
|
933
|
+
* `blockNodesOf` skips any top-level node whose OWN start line falls inside
|
|
934
|
+
* one of these, since that content is already projected as the question's
|
|
935
|
+
* `question`/`option` node pair, never a second, competing block node.
|
|
936
|
+
*/
|
|
937
|
+
const isInsideQuestionSpan = (spans: ReadonlyMap<number, number>, line: number): boolean => {
|
|
938
|
+
for (const [start, end] of spans) {
|
|
939
|
+
if (line >= start && line <= end) return true
|
|
940
|
+
}
|
|
941
|
+
return false
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
/**
|
|
945
|
+
* Every top-level block FIRST — without this a document with any question
|
|
946
|
+
* would drop its intro prose —
|
|
947
|
+
* then every question as a container with its options as children. `title` is
|
|
948
|
+
* the heading TEXT, never the first body line, which rides as `detail`. ONE
|
|
949
|
+
* parse plus one block walk, never one parse per question.
|
|
950
|
+
*/
|
|
951
|
+
const questionsView = (content: string): SteeringView => {
|
|
952
|
+
const tree = parseMarkdown(content)
|
|
953
|
+
const spans = questionEndLines(content)
|
|
954
|
+
const blockNodes = blockNodesOf(content, tree, {
|
|
955
|
+
skipNode: isQuestionsSectionHeading,
|
|
956
|
+
skipLine: (line) => isInsideQuestionSpan(spans, line),
|
|
957
|
+
})
|
|
958
|
+
const { questions } = parseOpenQuestions(content)
|
|
959
|
+
const noteAt = noteLookup(content)
|
|
960
|
+
return {
|
|
961
|
+
nodes: [
|
|
962
|
+
...blockNodes,
|
|
963
|
+
...questions.map((question, questionIndex) => ({
|
|
964
|
+
title: question.question,
|
|
965
|
+
detail: question.text,
|
|
966
|
+
detailInline: question.bodyInline,
|
|
967
|
+
status: question.status,
|
|
968
|
+
answered: question.answered,
|
|
969
|
+
anchor: { kind: "question" as const, index: questionIndex },
|
|
970
|
+
...(({ thread }) => (thread ? { thread } : {}))(noteAt(question.headingLine)),
|
|
971
|
+
body: question.bodyNodes,
|
|
972
|
+
children: question.options.map((option, index) => ({
|
|
973
|
+
title: option.text,
|
|
974
|
+
checked: option.checked,
|
|
975
|
+
anchor: { kind: "option" as const, questionIndex, index },
|
|
976
|
+
// The free-text slot never carries impacts of its own — see
|
|
977
|
+
// `QuestionOption.body`'s own doc comment.
|
|
978
|
+
...(option.freeText ? {} : { body: option.body }),
|
|
979
|
+
})),
|
|
980
|
+
})),
|
|
981
|
+
],
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
/** Resolves a `question` anchor: attaches at the end of the question's own heading line, the definition landing after the question's whole block (`questionEndLines`). `undefined` for a stale `index`. */
|
|
986
|
+
const resolveQuestionAnchor = (
|
|
987
|
+
content: string,
|
|
988
|
+
lines: readonly string[],
|
|
989
|
+
questions: readonly OpenQuestion[],
|
|
990
|
+
index: number,
|
|
991
|
+
): FootnoteAnchor | undefined => {
|
|
992
|
+
const question = questions[index]
|
|
993
|
+
if (!question) return undefined
|
|
994
|
+
const end = Math.max(
|
|
995
|
+
question.headingLine,
|
|
996
|
+
questionEndLines(content).get(question.headingLine) ?? question.headingLine,
|
|
997
|
+
)
|
|
998
|
+
return {
|
|
999
|
+
line: question.headingLine,
|
|
1000
|
+
endCharacter: (lines[question.headingLine] ?? "").length,
|
|
1001
|
+
blockEndLine: end,
|
|
1002
|
+
key: `question:${question.headingLine}`,
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
/** Resolves an `option` anchor: attaches at the end of the option's own source line, the definition landing after the option's whole span (`option.endLine`). `undefined` for a stale `questionIndex`/`index`. */
|
|
1007
|
+
const resolveOptionAnchor = (
|
|
1008
|
+
lines: readonly string[],
|
|
1009
|
+
questions: readonly OpenQuestion[],
|
|
1010
|
+
questionIndex: number,
|
|
1011
|
+
index: number,
|
|
1012
|
+
): FootnoteAnchor | undefined => {
|
|
1013
|
+
const option = questions[questionIndex]?.options[index]
|
|
1014
|
+
if (!option) return undefined
|
|
1015
|
+
return {
|
|
1016
|
+
line: option.sourceLine,
|
|
1017
|
+
endCharacter: (lines[option.sourceLine] ?? "").length,
|
|
1018
|
+
blockEndLine: option.endLine,
|
|
1019
|
+
key: `option:${option.sourceLine}`,
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
/** 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. */
|
|
1024
|
+
const resolveQuestionsParagraphAnchor = (
|
|
1025
|
+
tree: Root,
|
|
1026
|
+
lines: readonly string[],
|
|
1027
|
+
line: number,
|
|
1028
|
+
): FootnoteAnchor | undefined => {
|
|
1029
|
+
const block = blockNodeAt(tree, line)
|
|
1030
|
+
if (!block?.position) return undefined
|
|
1031
|
+
return {
|
|
1032
|
+
line,
|
|
1033
|
+
endCharacter: (lines[line] ?? "").length,
|
|
1034
|
+
blockEndLine: toLspPosition(block.position.end).line,
|
|
1035
|
+
key: `paragraph:${line}`,
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
/**
|
|
1040
|
+
* Resolves a `question`/`option`/`paragraph` `SteeringAnchor` against
|
|
1041
|
+
* `content` into the low-level `FootnoteAnchor` `Footnotes.ts#footnoteAttachEdits`
|
|
1042
|
+
* wants — `undefined` for a `chunk`/`hunk` anchor (not this format's own
|
|
1043
|
+
* kind) or an index/line that no longer resolves.
|
|
1044
|
+
*/
|
|
1045
|
+
const resolveQuestionsAnchor = (
|
|
1046
|
+
content: string,
|
|
1047
|
+
tree: Root,
|
|
1048
|
+
lines: readonly string[],
|
|
1049
|
+
questions: readonly OpenQuestion[],
|
|
1050
|
+
anchor: SteeringAnchor,
|
|
1051
|
+
): FootnoteAnchor | undefined => {
|
|
1052
|
+
switch (anchor.kind) {
|
|
1053
|
+
case "question":
|
|
1054
|
+
return resolveQuestionAnchor(content, lines, questions, anchor.index)
|
|
1055
|
+
case "option":
|
|
1056
|
+
return resolveOptionAnchor(lines, questions, anchor.questionIndex, anchor.index)
|
|
1057
|
+
case "paragraph":
|
|
1058
|
+
return resolveQuestionsParagraphAnchor(tree, lines, anchor.line)
|
|
1059
|
+
default:
|
|
1060
|
+
return undefined
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
/** `qa`-mode's `annotate`: resolves `anchor` then delegates the two-edit mechanics (`text` carried through verbatim as the new definition's body) to `Footnotes.ts#footnoteAttachEdits`. */
|
|
1065
|
+
const questionsAnnotate = (
|
|
1066
|
+
content: string,
|
|
1067
|
+
anchor: SteeringAnchor,
|
|
1068
|
+
text: string,
|
|
1069
|
+
): SteeringAnnotateResult => {
|
|
1070
|
+
const { questions } = parseOpenQuestions(content)
|
|
1071
|
+
const tree = parseMarkdown(content)
|
|
1072
|
+
const lines = content.split(/\r?\n/)
|
|
1073
|
+
const resolved = resolveQuestionsAnchor(content, tree, lines, questions, anchor)
|
|
1074
|
+
if (!resolved) return { ok: false, reason: "anchor-not-found" }
|
|
1075
|
+
const result = footnoteAttachEdits(content, resolved, text)
|
|
1076
|
+
if (!result.ok) return result
|
|
1077
|
+
return { ok: true, edits: result.edits }
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
/**
|
|
1081
|
+
* `qa`'s ticks ARE its answers — an "uncheck" that blindly cleared every
|
|
1082
|
+
* checked box would erase a human's answer, not reset read progress like
|
|
1083
|
+
* `review`'s hunk ticks. So `qa`'s `clearTicks` is a deliberate no-op: there
|
|
1084
|
+
* is nothing here that is ever safe to auto-clear.
|
|
1085
|
+
*/
|
|
1086
|
+
const qaClearTicks = (content: string): string => content
|
|
1087
|
+
|
|
1088
|
+
/** The `qa` steering descriptor: gtd's own in-process open-questions checkbox format — validation, outline, code actions, a footnote-only `pointerAt`, a no-op `clearTicks`, and the answer-completeness predicate (`unansweredQuestions`). Every structural finding carries a line and a range spanning the node it's about. */
|
|
1089
|
+
export const qaDescriptor: SteeringFormat = {
|
|
1090
|
+
sample: QA_SAMPLE,
|
|
1091
|
+
validate: (content) => [
|
|
1092
|
+
...parseOpenQuestions(content).findings,
|
|
1093
|
+
...parseFootnotes(content).findings,
|
|
1094
|
+
],
|
|
1095
|
+
outline: questionsOutline,
|
|
1096
|
+
actions: questionActions,
|
|
1097
|
+
pointerAt: questionsPointerAt,
|
|
1098
|
+
view: questionsView,
|
|
1099
|
+
annotate: questionsAnnotate,
|
|
1100
|
+
apply: questionsApply,
|
|
1101
|
+
clearTicks: qaClearTicks,
|
|
1102
|
+
unansweredQuestions,
|
|
1103
|
+
openThreads: openThreadsOf,
|
|
1104
|
+
}
|